From cfa59c2a5e756f68f25b5b0201a2e49253c7f190 Mon Sep 17 00:00:00 2001 From: Saranga Buwaneka Date: Sun, 28 Jun 2026 09:57:20 +0530 Subject: [PATCH 1/6] Harden payload injection filter against crashes and redundant work - Skip null action arguments and null collection items instead of throwing (optional/unbound parameters no longer 500 the request) - Add reference-based, path-scoped cycle detection so cyclic object graphs terminate instead of stack-overflowing at the default infinite recursion depth - Read each property value once instead of up to three GetValue calls - Cache PropertyInfo per type to avoid re-reflecting on every request - Guard AttributeRouteInfo for convention-routed controllers - Wire up the previously-dead WhiteListEntry.ExclusionPattern Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PayloadInjectionFilter.cs | 79 +++++++++--- .../PayloadInjectionFilterTests.cs | 118 +++++++++++++++++- 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/PayloadInjectionFilter/PayloadInjectionFilter.cs b/PayloadInjectionFilter/PayloadInjectionFilter.cs index 8693402..e2f0809 100644 --- a/PayloadInjectionFilter/PayloadInjectionFilter.cs +++ b/PayloadInjectionFilter/PayloadInjectionFilter.cs @@ -5,10 +5,10 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using System.Collections; -using Zone24x7PayloadExtensionFilter.HelperExtensions; +using SpitFirePayloadExtensionFilter.HelperExtensions; using System.Net; -namespace Zone24x7PayloadExtensionFilter +namespace SpitFirePayloadExtensionFilter { /// /// This filter intercepts the requests before it @@ -29,6 +29,13 @@ public class PayloadInjectionFilter : IActionFilter private List CaughtMaliciousContent; private ActionExecutingContext CurrentContext; + /// + /// Tracks the reference-type instances on the current traversal path so that + /// cyclic object graphs (e.g. A -> B -> A) do not cause infinite recursion. + /// Reference equality is used so that distinct sibling instances are not skipped. + /// + private readonly HashSet VisitedOnPath = new HashSet(ReferenceEqualityComparer.Instance); + private readonly ILogger logger; private readonly IOptions options; @@ -89,9 +96,14 @@ public void OnActionExecuting(ActionExecutingContext context) if (hasWhiteListedEntries) { - pathTemplate = context.ActionDescriptor.AttributeRouteInfo.Template; - templateMatch = options.Value.WhiteListEntries.Select(w => w.PathTemplate).Contains(pathTemplate); - whiteListIndex = (templateMatch) ? options.Value.WhiteListEntries.Select(w => w.PathTemplate).ToList().IndexOf(pathTemplate) : -1; + // AttributeRouteInfo is null for convention-routed controllers; guard against it. + pathTemplate = context.ActionDescriptor.AttributeRouteInfo?.Template; + + if (pathTemplate != null) + { + templateMatch = options.Value.WhiteListEntries.Select(w => w.PathTemplate).Contains(pathTemplate); + whiteListIndex = (templateMatch) ? options.Value.WhiteListEntries.Select(w => w.PathTemplate).ToList().IndexOf(pathTemplate) : -1; + } } if (context.IsOneOfAllowedHttpMethods(options.Value.AllowedHttpMethods!.Select(x => x.ToString()).Distinct().ToArray())) @@ -100,12 +112,15 @@ public void OnActionExecuting(ActionExecutingContext context) foreach (var argument in context.ActionArguments) { + // A nullable/optional bound parameter can be null; skip it instead of throwing. + if (argument.Value == null) continue; + if (hasWhiteListedEntries && whiteListIndex != -1) { parameterMatch = options.Value.WhiteListEntries[whiteListIndex].ParameterName.Equals(argument.Key); whiteListInitialCondition = parameterMatch && templateMatch; } - + var argumentType = argument.Value.GetType(); if (argumentType.IsString()) @@ -118,6 +133,7 @@ public void OnActionExecuting(ActionExecutingContext context) { foreach (var listItem in (argument.Value as IEnumerable)!) { + if (listItem == null) continue; Evaluate(listItem.GetType(), listItem, context, whiteListIndex, whiteListInitialCondition, ref PLACE_HOLDER_RECURSION_DEPTH); } } @@ -142,40 +158,75 @@ private void Evaluate(Type argType, object arg, ActionExecutingContext context, { if (arg != null && (!argType.IsValueType || argType.IsKeyValuePair())) { - IEnumerable properties = argType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + // Cycle guard: if this exact instance is already on the current traversal + // path, recursing again would loop forever. Track it for the duration of + // this subtree and remove it on the way out so siblings are still scanned. + bool tracked = !argType.IsValueType && VisitedOnPath.Add(arg); + if (!tracked && !argType.IsValueType) return; - if (properties.Any()) + try { + PropertyInfo[] properties = GetCachedProperties(argType); + foreach (var prop in properties) { isWhiteListedProperty = whiteListIndex != -1 && options.Value.WhiteListEntries[whiteListIndex].PropertyNames.Contains(prop.Name); if (prop.IsString() && !(initialWhiteListCondition && isWhiteListedProperty)) { - if (DetectDisallowedChars(prop.GetValue(arg) as string, options.Value.Pattern ?? DEFAULT_FILTER_PATTERN)) + var value = prop.GetValue(arg) as string; + if (DetectDisallowedChars(value, options.Value.Pattern ?? DEFAULT_FILTER_PATTERN)) + { + ShortCircuit(context, value); + } + } + else if (prop.IsString() && initialWhiteListCondition && isWhiteListedProperty + && whiteListIndex != -1 && options.Value.WhiteListEntries[whiteListIndex].ExclusionPattern != null) + { + // The property is white-listed, but an ExclusionPattern is configured: + // still short-circuit if the (otherwise allowed) value matches it. + var value = prop.GetValue(arg) as string; + if (DetectDisallowedChars(value, options.Value.WhiteListEntries[whiteListIndex].ExclusionPattern)) { - ShortCircuit(context, prop.GetValue(arg) as string); + ShortCircuit(context, value); } } else if (!prop.PropertyType.IsValueType) { + var value = prop.GetValue(arg); + if (value == null) continue; + if (prop.PropertyType.IsEnumerable()) { - if (prop.GetValue(arg) != null) + foreach (var item in (value as IEnumerable)) { - foreach (var item in (prop.GetValue(arg) as IEnumerable)) - Evaluate(item.GetType(), item, context, whiteListIndex, initialWhiteListCondition, ref CurrentRecursionDepth); + if (item == null) continue; + Evaluate(item.GetType(), item, context, whiteListIndex, initialWhiteListCondition, ref CurrentRecursionDepth); } } - else Evaluate(prop.PropertyType, prop.GetValue(arg), context, whiteListIndex, initialWhiteListCondition, ref CurrentRecursionDepth); + else Evaluate(prop.PropertyType, value, context, whiteListIndex, initialWhiteListCondition, ref CurrentRecursionDepth); } } } + finally + { + if (tracked) VisitedOnPath.Remove(arg); + } } } else RecursionDepthExceeded(context); } + /// + /// Caches the public instance properties per so the reflection + /// metadata lookup is performed only once per type rather than on every request. + /// + private static readonly System.Collections.Concurrent.ConcurrentDictionary PropertyCache + = new System.Collections.Concurrent.ConcurrentDictionary(); + + private static PropertyInfo[] GetCachedProperties(Type type) + => PropertyCache.GetOrAdd(type, t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance)); + private bool DetectDisallowedChars(string input, Regex disallowedPattern) { if (string.IsNullOrEmpty(input)) return false; diff --git a/PayloadInjectionFilter_Tests/PayloadInjectionFilterTests.cs b/PayloadInjectionFilter_Tests/PayloadInjectionFilterTests.cs index b69b9f0..6332f5d 100644 --- a/PayloadInjectionFilter_Tests/PayloadInjectionFilterTests.cs +++ b/PayloadInjectionFilter_Tests/PayloadInjectionFilterTests.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Zone24x7PayloadExtensionFilter; +using SpitFirePayloadExtensionFilter; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Routing; @@ -788,6 +788,122 @@ public void Exceeds_Defined_Recursive_Depth(string httpMethod) Assert.That((sanitizationFilter.GetCurrentContext().Result as ContentResult).StatusCode, Is.EqualTo(413)); } + [TestCase("POST")] + public void Does_Not_Throw_On_Null_Argument_Value(string httpMethod) + { + var mockLogger = new Mock>(); + var mockOptions = new Mock>(); + + mockOptions.Setup(x => x.Value).Returns(new PayloadInjectionOptions + { + AllowedHttpMethods = new List { HttpMethod.Put, HttpMethod.Post, HttpMethod.Patch }, + Pattern = new Regex(@"[<>\&;]") + }); + + var sanitizationFilter = new PayloadInjectionFilter(mockOptions.Object, mockLogger.Object); + var defaultHttpContext = new DefaultHttpContext(); + defaultHttpContext.Request.Method = httpMethod; + + // An optional / unbound parameter surfaces as a null action argument value. + var bodyWithNull = new Dictionary + { + { "optionalModel", null } + }; + + var ctrlActionDescriptor = new ControllerActionDescriptor { ControllerName = "Service" }; + var actionContext = new ActionContext(defaultHttpContext, new RouteData(), ctrlActionDescriptor, new ModelStateDictionary()); + var actionExecutingContext = new ActionExecutingContext(actionContext, new List(), bodyWithNull, null); + + Assert.DoesNotThrow(() => sanitizationFilter.OnActionExecuting(actionExecutingContext)); + Assert.That(sanitizationFilter.HasShortCircuited, Is.False); + } + + [TestCase("POST")] + public void Terminates_On_Cyclic_Object_Graph(string httpMethod) + { + var mockLogger = new Mock>(); + var mockOptions = new Mock>(); + + mockOptions.Setup(x => x.Value).Returns(new PayloadInjectionOptions + { + AllowedHttpMethods = new List { HttpMethod.Put, HttpMethod.Post, HttpMethod.Patch }, + Pattern = new Regex(@"[<>\&;]") + // MaxRecursionDepth left at the default (-1 / infinite) on purpose. + }); + + var sanitizationFilter = new PayloadInjectionFilter(mockOptions.Object, mockLogger.Object); + var defaultHttpContext = new DefaultHttpContext(); + defaultHttpContext.Request.Method = httpMethod; + + // Build a reference cycle: a -> b -> a + var a = new RecursiveType { Text1 = "safe", Text2 = "safe" }; + var b = new RecursiveType { Text1 = "", Text2 = "safe", Nested = a }; + a.Nested = b; + + var cyclicBody = new Dictionary { { "cyclic", a } }; + + var ctrlActionDescriptor = new ControllerActionDescriptor { ControllerName = "Service" }; + var actionContext = new ActionContext(defaultHttpContext, new RouteData(), ctrlActionDescriptor, new ModelStateDictionary()); + var actionExecutingContext = new ActionExecutingContext(actionContext, new List(), cyclicBody, null); + + // Without cycle detection this would StackOverflow with infinite recursion depth. + Assert.DoesNotThrow(() => sanitizationFilter.OnActionExecuting(actionExecutingContext)); + Assert.That(sanitizationFilter.HasShortCircuited, Is.True); + } + + [Test] + public void Applies_ExclusionPattern_To_Whitelisted_Property() + { + var mockLogger = new Mock>(); + var mockOptions = new Mock>(); + + mockOptions.Setup(x => x.Value).Returns(new PayloadInjectionOptions + { + AllowedHttpMethods = new List { HttpMethod.Put, HttpMethod.Post, HttpMethod.Patch }, + Pattern = new Regex(@"[<>\&;]"), + WhiteListEntries = new List + { + new WhiteListEntry + { + PathTemplate = "appointmentSettings/{id}", + ParameterName = "legitimateRichText", + PropertyNames = new List { nameof(LegitimateRichText.AllowedRichText) }, + // Even within a white-listed field, " + } + } + }; + + var ctrlActionDescriptor = new ControllerActionDescriptor + { + ControllerName = "Service", + AttributeRouteInfo = new AttributeRouteInfo { Template = "appointmentSettings/{id}" } + }; + + var actionContext = new ActionContext(defaultHttpContext, new RouteData(), ctrlActionDescriptor, new ModelStateDictionary()); + var actionExecutingContext = new ActionExecutingContext(actionContext, new List(), body, null); + + sanitizationFilter.OnActionExecuting(actionExecutingContext); + + Assert.That(sanitizationFilter.HasShortCircuited, Is.True); + } + [TestCase("PATCH")] public void Default_Recursion_Depth_Is_Minus_One(string httpMethod) { From 2e41b585824fe57ed4066737d186712933ee7b4e Mon Sep 17 00:00:00 2001 From: Saranga Buwaneka Date: Sun, 28 Jun 2026 09:57:30 +0530 Subject: [PATCH 2/6] Add request-scanning middleware Adds PayloadInjectionMiddleware as an alternative to the MVC action filter. It runs before model binding and scans the raw query string and request body, so it covers minimal APIs, Razor Pages and gRPC in addition to MVC controllers. - AddPayloadInjectionMiddleware / UsePayloadInjectionMiddleware helpers - Method gating, per-path exclusions, query/body scan toggles - URL-decodes the query string to catch percent-encoded payloads - Buffers and rewinds the body so downstream endpoints can re-read it - Rejects bodies larger than MaxScannedBodyBytes with 413 - Unit tests covering body/query short-circuit, rewind, exclusions, custom responses and the oversized-body path Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PayloadInjectionMiddleware.cs | 132 ++++++++++++++++ .../PayloadInjectionMiddlewareExtensions.cs | 34 +++++ .../PayloadInjectionMiddlewareOptions.cs | 71 +++++++++ .../PayloadInjectionMiddlewareTests.cs | 143 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 PayloadInjectionFilter/PayloadInjectionMiddleware.cs create mode 100644 PayloadInjectionFilter/PayloadInjectionMiddlewareExtensions.cs create mode 100644 PayloadInjectionFilter/PayloadInjectionMiddlewareOptions.cs create mode 100644 PayloadInjectionFilter_Tests/PayloadInjectionMiddlewareTests.cs diff --git a/PayloadInjectionFilter/PayloadInjectionMiddleware.cs b/PayloadInjectionFilter/PayloadInjectionMiddleware.cs new file mode 100644 index 0000000..b5d7932 --- /dev/null +++ b/PayloadInjectionFilter/PayloadInjectionMiddleware.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace SpitFirePayloadExtensionFilter +{ + /// + /// Middleware that scans the raw request (query string and body) for disallowed content + /// before it reaches the endpoint pipeline. Because it runs before model binding it covers + /// MVC controllers, minimal APIs, Razor Pages and gRPC alike. + /// + /// This is a defense-in-depth boundary check, not a substitute for output encoding, + /// parameterized queries or a dedicated WAF. Deny-listing characters such as + /// < > & ; will reject some legitimate input — exclude such routes via + /// . + /// + /// + public class PayloadInjectionMiddleware + { + private readonly RequestDelegate next; + private readonly ILogger logger; + private readonly PayloadInjectionMiddlewareOptions options; + private readonly HashSet methods; + private readonly Regex pattern; + + public PayloadInjectionMiddleware( + RequestDelegate next, + IOptions options, + ILogger logger) + { + this.next = next; + this.logger = logger; + this.options = options.Value; + this.pattern = this.options.Pattern ?? new Regex(@"[<>\&;]"); + this.methods = new HashSet( + (this.options.AllowedHttpMethods ?? new List()).Select(m => m.Method), + StringComparer.OrdinalIgnoreCase); + } + + public async Task InvokeAsync(HttpContext context) + { + var request = context.Request; + + if (!methods.Contains(request.Method) || IsExcluded(request.Path)) + { + await next(context); + return; + } + + if (options.ScanQueryString && request.QueryString.HasValue) + { + // Decode so percent-encoded payloads (e.g. %3Cscript%3E) are caught too. + var query = Uri.UnescapeDataString(request.QueryString.Value!); + if (pattern.IsMatch(query)) + { + await ShortCircuitAsync(context, "query string"); + return; + } + } + + if (options.ScanBody && HasBody(request)) + { + if (request.ContentLength.HasValue && request.ContentLength.Value > options.MaxScannedBodyBytes) + { + await RejectOversizedAsync(context); + return; + } + + var body = await ReadBodyAsync(request); + if (body != null && pattern.IsMatch(body)) + { + await ShortCircuitAsync(context, "request body"); + return; + } + } + + await next(context); + } + + private bool IsExcluded(PathString path) + { + if (options.ExcludedPaths == null || options.ExcludedPaths.Count == 0) return false; + return options.ExcludedPaths.Any(p => path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasBody(HttpRequest request) + => (request.ContentLength ?? 0) > 0 || request.Headers.ContainsKey("Transfer-Encoding"); + + private async Task ReadBodyAsync(HttpRequest request) + { + // Buffer the body so it can be re-read by the endpoint after scanning. + request.EnableBuffering(); + + using var reader = new StreamReader( + request.Body, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: false, + bufferSize: 1024, + leaveOpen: true); + + var body = await reader.ReadToEndAsync(); + request.Body.Position = 0; + return body; + } + + private async Task ShortCircuitAsync(HttpContext context, string location) + { + logger.LogWarning( + "[PayloadInjectionMiddleware]:[Warning] Request short-circuited due to malicious content in the {Location}.", + location); + + context.Response.Clear(); + context.Response.StatusCode = options.ResponseStatusCode == 0 ? 400 : options.ResponseStatusCode; + context.Response.ContentType = options.ResponseContentType ?? "text/plain"; + await context.Response.WriteAsync( + options.ResponseContentBody ?? "Request short-circuited due to malicious content."); + } + + private async Task RejectOversizedAsync(HttpContext context) + { + logger.LogWarning("[PayloadInjectionMiddleware]:[Warning] Request body exceeds the scannable limit of {Limit} bytes.", options.MaxScannedBodyBytes); + + context.Response.Clear(); + context.Response.StatusCode = (int)HttpStatusCode.RequestEntityTooLarge; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync("Request body is too large to scan."); + } + } +} diff --git a/PayloadInjectionFilter/PayloadInjectionMiddlewareExtensions.cs b/PayloadInjectionFilter/PayloadInjectionMiddlewareExtensions.cs new file mode 100644 index 0000000..5ecbedd --- /dev/null +++ b/PayloadInjectionFilter/PayloadInjectionMiddlewareExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace SpitFirePayloadExtensionFilter +{ + /// + /// Registration helpers for the . + /// + public static class PayloadInjectionMiddlewareExtensions + { + /// + /// Registers and configures the options used by . + /// Call this in service configuration, then call + /// in the request pipeline. + /// + public static IServiceCollection AddPayloadInjectionMiddleware( + this IServiceCollection services, + Action configure = null) + { + if (configure != null) services.Configure(configure); + else services.Configure(_ => { }); + + return services; + } + + /// + /// Adds the payload injection scanning middleware to the request pipeline. Place this + /// early — before UseRouting/MapControllers — so malicious requests are + /// short-circuited before they reach any endpoint. + /// + public static IApplicationBuilder UsePayloadInjectionMiddleware(this IApplicationBuilder app) + => app.UseMiddleware(); + } +} diff --git a/PayloadInjectionFilter/PayloadInjectionMiddlewareOptions.cs b/PayloadInjectionFilter/PayloadInjectionMiddlewareOptions.cs new file mode 100644 index 0000000..ddc0f44 --- /dev/null +++ b/PayloadInjectionFilter/PayloadInjectionMiddlewareOptions.cs @@ -0,0 +1,71 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; + +namespace SpitFirePayloadExtensionFilter +{ + /// + /// Options for the . + /// + /// Unlike the MVC action filter, the middleware runs before model binding and inspects + /// the raw request (query string and body) as text. It therefore works for any endpoint + /// type ASP.NET Core supports — MVC controllers, minimal APIs, Razor Pages and gRPC — at + /// the cost of not being able to target individual bound model properties. Use the + /// list to allow specific routes through unscanned. + /// + /// + public class PayloadInjectionMiddlewareOptions + { + /// + /// Regex describing disallowed / malicious content. Defaults to [<>\&;]. + /// + public Regex Pattern { get; set; } = new Regex(@"[<>\&;]"); + + /// + /// HTTP methods to scan. Defaults to POST, PUT and PATCH. + /// + public List AllowedHttpMethods { get; set; } = new List + { + HttpMethod.Post, + HttpMethod.Put, + HttpMethod.Patch, + }; + + /// + /// Status code returned when a request is short-circuited. Defaults to 400. + /// + public int ResponseStatusCode { get; set; } = 400; + + /// + /// Content type of the short-circuit response. Defaults to text/plain. + /// + public string ResponseContentType { get; set; } = "text/plain"; + + /// + /// Body of the short-circuit response. + /// + public string ResponseContentBody { get; set; } = "Request short-circuited due to malicious content."; + + /// + /// Whether to scan the request query string. Defaults to true. + /// + public bool ScanQueryString { get; set; } = true; + + /// + /// Whether to scan the request body. Defaults to true. + /// + public bool ScanBody { get; set; } = true; + + /// + /// Maximum body size (in bytes) that will be buffered and scanned. Requests with a + /// larger declared Content-Length are rejected with HTTP 413 rather than buffered, + /// which bounds the memory cost of scanning. Defaults to 30 MB. + /// + public long MaxScannedBodyBytes { get; set; } = 30L * 1024 * 1024; + + /// + /// Request paths (prefix match) that should bypass scanning entirely. Useful for + /// endpoints that legitimately accept rich text / markup. + /// + public List ExcludedPaths { get; set; } = new List(); + } +} diff --git a/PayloadInjectionFilter_Tests/PayloadInjectionMiddlewareTests.cs b/PayloadInjectionFilter_Tests/PayloadInjectionMiddlewareTests.cs new file mode 100644 index 0000000..631a817 --- /dev/null +++ b/PayloadInjectionFilter_Tests/PayloadInjectionMiddlewareTests.cs @@ -0,0 +1,143 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using SpitFirePayloadExtensionFilter; + +namespace PayloadInjectionFilter_Tests +{ + [TestFixture] + public class PayloadInjectionMiddlewareTests + { + private static PayloadInjectionMiddleware Build(PayloadInjectionMiddlewareOptions opts, RequestDelegate next) + { + var logger = new Mock>(); + var options = new Mock>(); + options.Setup(x => x.Value).Returns(opts); + return new PayloadInjectionMiddleware(next, options.Object, logger.Object); + } + + private static DefaultHttpContext ContextWith(string method, string body = null, string queryString = null) + { + var ctx = new DefaultHttpContext(); + ctx.Request.Method = method; + ctx.Response.Body = new MemoryStream(); + + if (queryString != null) ctx.Request.QueryString = new QueryString(queryString); + + if (body != null) + { + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Request.Body = new MemoryStream(bytes); + ctx.Request.ContentLength = bytes.Length; + } + + return ctx; + } + + [Test] + public async Task ShortCircuits_Malicious_Body() + { + var nextCalled = false; + var middleware = Build(new PayloadInjectionMiddlewareOptions(), _ => { nextCalled = true; return Task.CompletedTask; }); + + var ctx = ContextWith("POST", body: "{\"name\":\"\"}"); + await middleware.InvokeAsync(ctx); + + Assert.That(nextCalled, Is.False); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(400)); + } + + [Test] + public async Task Allows_Clean_Body_And_Body_Is_Rewound_For_Downstream() + { + string seenByEndpoint = null; + var middleware = Build(new PayloadInjectionMiddlewareOptions(), async ctx => + { + using var reader = new StreamReader(ctx.Request.Body); + seenByEndpoint = await reader.ReadToEndAsync(); + }); + + var ctx = ContextWith("POST", body: "{\"name\":\"perfectly fine\"}"); + await middleware.InvokeAsync(ctx); + + Assert.That(ctx.Response.StatusCode, Is.EqualTo(200)); + Assert.That(seenByEndpoint, Is.EqualTo("{\"name\":\"perfectly fine\"}")); + } + + [Test] + public async Task ShortCircuits_Malicious_QueryString() + { + var nextCalled = false; + var middleware = Build(new PayloadInjectionMiddlewareOptions(), _ => { nextCalled = true; return Task.CompletedTask; }); + + var ctx = ContextWith("POST", queryString: "?q=%3Cscript%3E"); + await middleware.InvokeAsync(ctx); + + Assert.That(nextCalled, Is.False); + Assert.That(ctx.Response.StatusCode, Is.EqualTo(400)); + } + + [Test] + public async Task Does_Not_Scan_Disallowed_Methods() + { + var nextCalled = false; + var middleware = Build(new PayloadInjectionMiddlewareOptions(), _ => { nextCalled = true; return Task.CompletedTask; }); + + var ctx = ContextWith("GET", body: "