feat(inboundapi): bound audit capture at InboundMaxBytes (memory safety)
AuditWriteMiddleware previously buffered the FULL request and response bodies into memory and only let DefaultAuditPayloadFilter trim them after persistence. A 500 MiB upload allocated 500 MiB of MemoryStream plus 1 GiB of UTF-16 string transiently before the filter pulled it back to the 1 MiB inbound ceiling — the cap was real on the persisted row but not at the capture site. Inject IOptionsMonitor<AuditLogOptions> and read InboundMaxBytes per-request (same convention as DefaultAuditPayloadFilter so a live config change picks up the next request). The request reader now pulls at most cap + 1 bytes into a UTF-8 byte-safe-truncated string and rewinds the stream so the endpoint handler still sees the full body. The response wrap is a new CapturedResponseStream that forwards every Write / WriteAsync to the real sink (the client still receives all bytes) while capturing at most cap + 1 bytes for the audit copy. The middleware now sets PayloadTruncated itself when either body hit the cap; the filter still OR's its own determination on top. Adds a project reference from ScadaLink.InboundAPI to ScadaLink.AuditLog so AuditLogOptions resolves. AuditLog does NOT reference InboundAPI back, so no cycle is introduced. Tests: - All 21 existing AuditWriteMiddlewareTests still pass (the helper gains an optional AuditLogOptions argument; default is the standard 1 MiB ceiling so existing small-body tests are unaffected). - MiddlewareOrderTests' construction site updated for the new ctor arg; a StaticAuditLogOptionsMonitor file-local double mirrors the InboundChannelCapTests pattern. - New RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue pins a 4 KiB cap against a 20 KB body: audit copy <= 4 KiB, PayloadTruncated = true, downstream handler reads the full 20 KB. - New ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue pins the same shape on the response side: client sink receives 20 KB, audit copy <= 4 KiB, PayloadTruncated = true. InboundAPI test count: 133 -> 135.
This commit is contained in:
@@ -4,6 +4,8 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
@@ -79,8 +81,32 @@ public class AuditWriteMiddlewareTests
|
||||
|
||||
private static AuditWriteMiddleware CreateMiddleware(
|
||||
RequestDelegate next,
|
||||
ICentralAuditWriter writer) =>
|
||||
new(next, writer, NullLogger<AuditWriteMiddleware>.Instance);
|
||||
ICentralAuditWriter writer,
|
||||
AuditLogOptions? options = null) =>
|
||||
new(
|
||||
next,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()));
|
||||
|
||||
/// <summary>
|
||||
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
|
||||
/// same snapshot on every read, no change-token plumbing required. Mirrors the
|
||||
/// <c>StaticMonitor</c> pattern in
|
||||
/// <c>tests/ScadaLink.AuditLog.Tests/Payload/InboundChannelCapTests.cs</c>.
|
||||
/// </summary>
|
||||
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Happy path — InboundRequest/Delivered/HttpStatus 200
|
||||
@@ -581,4 +607,86 @@ public class AuditWriteMiddlewareTests
|
||||
Assert.Equal(AuditStatus.Failed, evt.Status);
|
||||
Assert.Equal("partial", evt.ResponseSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Bounded audit capture — memory safety follow-up. The capture site now
|
||||
// honours AuditLogOptions.InboundMaxBytes at READ time (not just at
|
||||
// filter-time), so a 500 MiB body cannot transiently allocate 500 MiB of
|
||||
// string. The cap is local to the AUDIT copy; downstream readers and the
|
||||
// real client still see every byte.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
|
||||
{
|
||||
// 4 KiB cap, 20 KB body — the audit copy must be UTF-8 byte-safe
|
||||
// capped at 4 KiB AND PayloadTruncated must flip, while the
|
||||
// downstream handler still sees the full 20 KB payload.
|
||||
const int cap = 4096;
|
||||
var bigBody = new string('a', 20_000);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext(body: bigBody);
|
||||
|
||||
string? observedAfterMiddleware = null;
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
using var reader = new StreamReader(hc.Request.Body);
|
||||
observedAfterMiddleware = await reader.ReadToEndAsync();
|
||||
hc.Response.StatusCode = 200;
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap });
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// (iii) Downstream handler still sees the FULL body — the cap applied
|
||||
// only to the audit copy.
|
||||
Assert.Equal(bigBody, observedAfterMiddleware);
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// (i) Audit copy bounded at cap bytes (UTF-8 byte count).
|
||||
Assert.NotNull(evt.RequestSummary);
|
||||
Assert.True(
|
||||
Encoding.UTF8.GetByteCount(evt.RequestSummary!) <= cap,
|
||||
$"RequestSummary byte count {Encoding.UTF8.GetByteCount(evt.RequestSummary!)} exceeded cap {cap}");
|
||||
// (ii) Truncation flag set by the middleware (the filter will OR its
|
||||
// own determination on top, but the middleware MUST set it itself).
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
|
||||
{
|
||||
// 4 KiB cap, 20 KB response — the test sink (acts as the real client)
|
||||
// MUST receive all 20 KB while the audit copy is bounded at 4 KiB.
|
||||
const int cap = 4096;
|
||||
var bigResponse = new string('b', 20_000);
|
||||
var writer = new RecordingAuditWriter();
|
||||
var ctx = BuildContext();
|
||||
var captured = new MemoryStream();
|
||||
ctx.Response.Body = captured; // stand-in for the client sink
|
||||
|
||||
var mw = CreateMiddleware(
|
||||
async hc =>
|
||||
{
|
||||
hc.Response.StatusCode = 200;
|
||||
await hc.Response.WriteAsync(bigResponse);
|
||||
},
|
||||
writer,
|
||||
options: new AuditLogOptions { InboundMaxBytes = cap });
|
||||
|
||||
await mw.InvokeAsync(ctx);
|
||||
|
||||
// Client sink received every byte — the forwarding wrap is transparent.
|
||||
Assert.Equal(bigResponse, Encoding.UTF8.GetString(captured.ToArray()));
|
||||
|
||||
var evt = Assert.Single(writer.Events);
|
||||
// Audit copy bounded at cap bytes.
|
||||
Assert.NotNull(evt.ResponseSummary);
|
||||
Assert.True(
|
||||
Encoding.UTF8.GetByteCount(evt.ResponseSummary!) <= cap,
|
||||
$"ResponseSummary byte count {Encoding.UTF8.GetByteCount(evt.ResponseSummary!)} exceeded cap {cap}");
|
||||
Assert.True(evt.PayloadTruncated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.AuditLog.Configuration;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
@@ -145,7 +147,8 @@ public class MiddlewareOrderTests
|
||||
// instantiates the type correctly.
|
||||
_ => Task.CompletedTask,
|
||||
writer,
|
||||
NullLogger<AuditWriteMiddleware>.Instance));
|
||||
NullLogger<AuditWriteMiddleware>.Instance,
|
||||
new StaticAuditLogOptionsMonitor(new AuditLogOptions())));
|
||||
services.AddRouting();
|
||||
services.AddAuthorization();
|
||||
services.AddAuthentication("TestScheme")
|
||||
@@ -233,4 +236,22 @@ public class MiddlewareOrderTests
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
|
||||
/// same snapshot on every read. Mirrors the helper in
|
||||
/// <c>AuditWriteMiddlewareTests</c>.
|
||||
/// </summary>
|
||||
private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
|
||||
{
|
||||
private readonly AuditLogOptions _value;
|
||||
|
||||
public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;
|
||||
|
||||
public AuditLogOptions CurrentValue => _value;
|
||||
|
||||
public AuditLogOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user