using System.Net; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; public class RelayProvenanceTests { [Theory] [InlineData("none")] [InlineData("body")] [InlineData("path")] [InlineData("credential")] [InlineData("expired")] [InlineData("wrongkey")] public async Task EI5_GenuineRelayPreservesCallerIp_OnlyForIntactSignedRequest(string tamper) { var options = new Monitor(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer"] }); var writer = new Writer(); var handler = new Handler(async request => { var active = Context(tamper == "body" ? "changed" : await request.Content!.ReadAsStringAsync(), "10.0.0.2"); foreach (var header in request.Headers) active.Request.Headers[header.Key] = header.Value.ToArray(); active.Request.ContentType = request.Content!.Headers.ContentType?.ToString(); if (tamper == "path") active.Request.Path = "/api/another"; if (tamper == "credential") active.Request.Headers["X-API-Key"] = "changed-key"; if (tamper is "expired" or "wrongkey") { var envelope = active.Request.Headers["X-ScadaBridge-Relay"].ToString().Split('.'); var node = System.Text.Json.Nodes.JsonNode.Parse(Convert.FromBase64String(envelope[0]))!; if (tamper == "expired") node["Time"] = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds(); var bytes = JsonSerializer.SerializeToUtf8Bytes(node); var pepper = Encoding.UTF8.GetBytes(new string(tamper == "wrongkey" ? 'x' : 'p', 32)); var key = System.Security.Cryptography.HMACSHA256.HashData(pepper, "ScadaBridge.InboundRelay.v1"u8); var mac = System.Security.Cryptography.HMACSHA256.HashData(key, bytes); active.Request.Headers["X-ScadaBridge-Relay"] = Convert.ToBase64String(bytes) + "." + Convert.ToHexString(mac); } await AuditAsync(active, writer, options); return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; }); var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger.Instance); var original = Context("{}", "10.4.5.6"); original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey] = Guid.NewGuid(); original.Request.Headers["X-ScadaBridge-Relay"] = "caller-forged-proof"; original.Request.Headers["X-Forwarded-For"] = "6.6.6.6"; await forwarder.ForwardAsync(original, CancellationToken.None); var row = Assert.Single(writer.Events).AsRow(); using var extra = JsonDocument.Parse(row.Extra!); Assert.Equal(tamper != "none" ? "10.0.0.2" : "10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString()); if (tamper == "none") Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId); } [Fact] public async Task EI5_DirectForgedForwardingHeadersCannotChangeAuditedCaller() { var context = Context("{}", "10.4.5.6"); context.Request.Headers[HttpStandbyRequestForwarder.ForwardedRequestHeader] = "trusted-peer"; context.Request.Headers["X-ScadaBridge-Relay"] = "forged"; context.Request.Headers["X-Forwarded-For"] = "6.6.6.6"; var writer = new Writer(); await AuditAsync(context, writer, new Monitor(new() { ApiKeyPepper = new string('p', 32) })); using var extra = JsonDocument.Parse(Assert.Single(writer.Events).AsRow().Extra!); Assert.Equal("10.4.5.6", extra.RootElement.GetProperty("remoteIp").GetString()); } [Fact] public async Task EI1_AllAuthenticatedPeersStandby_OnlyOriginEmitsFailureAudit() { var options = new Monitor(new() { ApiKeyPepper = new string('p', 32), StandbyForwardPeerEndpoints = ["http://peer-a", "http://peer-b"] }); var writer = new Writer(); var attempts = 0; var handler = new Handler(async request => { attempts++; var peer = Context(await request.Content!.ReadAsStringAsync(), "10.0.0.2"); foreach (var header in request.Headers) peer.Request.Headers[header.Key] = header.Value.ToArray(); peer.Request.ContentType = request.Content!.Headers.ContentType?.ToString(); await AuditAsync(peer, writer, options, new Standby()); var response = new HttpResponseMessage((HttpStatusCode)peer.Response.StatusCode) { Content = new StringContent("{}") }; response.Headers.Add(HttpStandbyRequestForwarder.StandbyResponseHeader, "1"); return response; }); var forwarder = new HttpStandbyRequestForwarder(new Factory(handler), options, NullLogger.Instance); var original = Context("{}", "10.4.5.6"); await AuditAsync(original, writer, options, new Standby(), forwarder); Assert.Equal(2, attempts); Assert.Equal(503, original.Response.StatusCode); var row = Assert.Single(writer.Events).AsRow(); Assert.Equal(original.Items[AuditWriteMiddleware.InboundExecutionIdItemKey], row.ExecutionId); } private sealed class Standby : IActiveNodeGate { public bool IsActiveNode => false; } private static async Task AuditAsync(HttpContext context, Writer writer, IOptionsMonitor options, IActiveNodeGate? gate = null, IStandbyRequestForwarder? forwarder = null) { var services = new ServiceCollection().AddLogging(); services.AddSingleton(writer); services.AddSingleton(options); if (gate is not null) services.AddSingleton(gate); if (forwarder is not null) services.AddSingleton(forwarder); services.AddSingleton>(new Monitor(new())); using var provider = services.BuildServiceProvider(); context.RequestServices = provider; var middleware = ActivatorUtilities.CreateInstance(provider, (RequestDelegate)(async ctx => { if (gate is null) { ctx.Response.StatusCode = 200; return; } var filter = new InboundApiEndpointFilter(NullLogger.Instance, options); var result = await filter.InvokeAsync(EndpointFilterInvocationContext.Create(ctx), _ => ValueTask.FromResult(Results.Ok())); await ((IResult)result!).ExecuteAsync(ctx); })); await middleware.InvokeAsync(context); } private static DefaultHttpContext Context(string body, string ip) { var context = new DefaultHttpContext(); context.Request.Method = "POST"; context.Request.Path = "/api/echo"; context.Request.RouteValues["methodName"] = "echo"; context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); context.Request.ContentType = "application/json"; context.Response.Body = new MemoryStream(); context.Connection.RemoteIpAddress = IPAddress.Parse(ip); return context; } private sealed class Writer : ICentralAuditWriter { public List Events { get; } = []; public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } private sealed class Monitor(T value) : IOptionsMonitor { public T CurrentValue => value; public T Get(string? name) => value; public IDisposable? OnChange(Action listener) => null; } private sealed class Handler(Func> send) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request); } private sealed class Factory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, false); } }