diff --git a/.github/workflows/dotnet-publish.yml b/.github/workflows/dotnet-publish.yml index 9b9ab5f..0e0e3fa 100644 --- a/.github/workflows/dotnet-publish.yml +++ b/.github/workflows/dotnet-publish.yml @@ -1,21 +1,38 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net +# This workflow builds and publishes the NuGet package using Trusted Publishing +# (OIDC), so no long-lived API key is stored in the repo. +# See: https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing +# +# One-time setup on nuget.org (Account -> Trusted Publishing): add a policy with +# Repository Owner: bsaranga +# Repository: PayloadInjectionFilter +# Workflow File: dotnet-publish.yml +# Environment: release (optional, but recommended; must match the +# job-level `environment: release` below) +# and add a repository secret NUGET_USER set to your nuget.org username (the +# profile name, NOT your email address). The `release` environment can also +# be configured in GitHub (Settings -> Environments) with required reviewers +# so a publish must be manually approved. name: Nuget Package Publish Workflow on: - pull_request: + push: branches: [ "main" ] jobs: - build: + build-and-publish: runs-on: ubuntu-latest + environment: release + + permissions: + id-token: write # required for GitHub to issue the OIDC token + contents: read steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 6.0.x - name: Restore dependencies @@ -25,6 +42,11 @@ jobs: - name: Pack run: dotnet pack -o . -c Release working-directory: ./PayloadInjectionFilter + - name: NuGet login (OIDC -> short-lived API key) + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} - name: Publish - run: dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + run: dotnet nuget push *.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate working-directory: ./PayloadInjectionFilter diff --git a/PayloadInjectionFilter/HelperExtensions.cs b/PayloadInjectionFilter/HelperExtensions.cs index 802ed85..e6ee463 100644 --- a/PayloadInjectionFilter/HelperExtensions.cs +++ b/PayloadInjectionFilter/HelperExtensions.cs @@ -1,7 +1,7 @@ using System.Reflection; using Microsoft.AspNetCore.Mvc.Filters; -namespace Zone24x7PayloadExtensionFilter.HelperExtensions +namespace SpitFirePayloadExtensionFilter.HelperExtensions { public static class HelperExtensions { 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/PayloadInjectionFilter.csproj b/PayloadInjectionFilter/PayloadInjectionFilter.csproj index f994b30..6002aca 100644 --- a/PayloadInjectionFilter/PayloadInjectionFilter.csproj +++ b/PayloadInjectionFilter/PayloadInjectionFilter.csproj @@ -3,10 +3,10 @@ net6.0 enable - Zone24x7.PayloadInjectionFilter - 0.0.9 + SpitFire.PayloadInjectionFilter + 0.1.0 Buwaneka Saranga - Zone24x7 Inc. + SpitFire Inc. PayloadInjectionFilter For ASPNET Core A reusable payload injection filter for ASP.NET Core. This can be used to short-circuit a request if it contains any malicious content as specified via a regex pattern. diff --git a/PayloadInjectionFilter/PayloadInjectionFilterConfigurationExtensions.cs b/PayloadInjectionFilter/PayloadInjectionFilterConfigurationExtensions.cs index 1083602..f7e9386 100644 --- a/PayloadInjectionFilter/PayloadInjectionFilterConfigurationExtensions.cs +++ b/PayloadInjectionFilter/PayloadInjectionFilterConfigurationExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -namespace Zone24x7PayloadExtensionFilter +namespace SpitFirePayloadExtensionFilter { /// /// Contains payload injection filter extensible methods 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/PayloadInjectionOptions.cs b/PayloadInjectionFilter/PayloadInjectionOptions.cs index 349697e..0d457ad 100644 --- a/PayloadInjectionFilter/PayloadInjectionOptions.cs +++ b/PayloadInjectionFilter/PayloadInjectionOptions.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace Zone24x7PayloadExtensionFilter +namespace SpitFirePayloadExtensionFilter { /// /// Set fine-tuning options for the payload filter diff --git a/PayloadInjectionFilter/ReadMe.md b/PayloadInjectionFilter/ReadMe.md index 1a43503..d81641c 100644 --- a/PayloadInjectionFilter/ReadMe.md +++ b/PayloadInjectionFilter/ReadMe.md @@ -1,14 +1,37 @@ # Payload Injection Filter for ASP.NET Core -A reusable payload injection filter for ASP.NET Core. This can be used to short-circuit a request if it contains any malicious content as specified via a regex pattern. +A reusable payload injection filter for ASP.NET Core. This can be used to short-circuit a request if it contains any malicious contents in the JSON payload or HTTP query parameters. The detection can be specified via a regex pattern. [![Build/Test Workflow](https://github.com/bsaranga/PayloadInjectionFilter/actions/workflows/dotnet-build.yml/badge.svg)](https://github.com/bsaranga/PayloadInjectionFilter/actions/workflows/dotnet-build.yml) [![Nuget Package Publish Workflow](https://github.com/bsaranga/PayloadInjectionFilter/actions/workflows/dotnet-publish.yml/badge.svg)](https://github.com/bsaranga/PayloadInjectionFilter/actions/workflows/dotnet-publish.yml) -Available on [NuGet](https://www.nuget.org/packages/Zone24x7.PayloadInjectionFilter/) +Available on [NuGet](https://www.nuget.org/packages/SpitFire.PayloadInjectionFilter/) + +The package ships **two interchangeable mechanisms**: + +| | MVC Action Filter | Middleware | +|---|---|---| +| Method | `AddPayloadInjectionFilter` | `AddPayloadInjectionMiddleware` / `UsePayloadInjectionMiddleware` | +| Runs | after model binding | before routing & model binding | +| Sees | the bound model object graph | the raw query string and request body | +| Covers | MVC controllers only | controllers, **minimal APIs**, Razor Pages, gRPC | +| Granularity | per-property white-listing | per-path exclusion | + +Use the **filter** when you want property-level white-listing inside MVC controllers. Use the **middleware** when you want a single boundary check that covers every endpoint type (including minimal APIs) and runs before any binding cost is incurred. They can also be combined. + +## Features + +1. Pattern based detection of malicious strings in JSON payloads +2. Specify the HTTP methods on which the filter/middleware should execute +3. Specify the short-circuit response by setting status code, content type and body +4. Supports recursive payloads, including recursion limit specification, with built-in protection against cyclic object graphs +5. Specific properties in specific endpoints can be white-listed (filter), or whole paths excluded (middleware) +6. Optional per-entry `ExclusionPattern` that still rejects disallowed content inside otherwise white-listed properties + +> **Scope — read this first.** This is a *defense-in-depth* boundary check, not a complete security control. Deny-listing characters such as `< > & ;` does **not** by itself protect against XSS or SQL injection — the real defenses remain output encoding (Razor auto-encoding, `HtmlEncoder`) and parameterized queries / an ORM. Deny-listing will also reject some legitimate input (names with `&`, rich-text fields, etc.); white-list those properties or exclude those paths. Treat this library as one cheap layer in front of those primary defenses, not a replacement for a WAF. ## Usage -The `AddPayloadInjectionFilter` method can be chained to the `AddControllers` method in a usual ASP.NET Core service configuration section. For the filter to work, you must specify the HTTP methods that it should operate on and the regex pattern that specifies a match for malicious content. The filter will check model bound bodies such as custom types and plain strings bound from query parameters. +The `AddPayloadInjectionFilter` method can be chained to the `AddControllers` method in a usual ASP.NET Core service configuration section. For the filter to work, you must specify the HTTP methods that it should operate on and the regex pattern that specifies a match for malicious content. The filter will check model bound objects such as custom types and plain strings bound from query parameters. ```csharp public void ConfigureServices(IServiceCollection services) @@ -28,7 +51,7 @@ public void ConfigureServices(IServiceCollection services) } ``` -By default, the filter will short-circuit the request and return a response with HTTP Status 400, and a plain text message saying, `Request short-circuited due to malicious content.`. This can be overridden as shown below, +By default, the filter will short-circuit the request and return a response with an HTTP Status of 400, and a plain text message saying, `Request short-circuited due to malicious content.`. This can be overridden as shown below, ```csharp public void ConfigureServices(IServiceCollection services) @@ -109,19 +132,108 @@ public void ConfigureServices(IServiceCollection services) } ``` -Further more, selected properties in the models bound selected API endpoints can be ignored by specification. +Further more, selected properties in the models bound to selected API endpoints can be ignored by specification. ```csharp - cfg.WhiteListEntries = new List - { - new WhiteListEntry - { - PathTemplate = "api/Services/appointmentSettings/{id}", - ParameterName = "appointmentSetting", - PropertyNames = new List - { - nameof(ServiceAppointmentSetting.AdditoinalInformation) - } - } - }; +cfg.WhiteListEntries = new List +{ + new WhiteListEntry + { + PathTemplate = "api/Services/appointmentSettings/{id}", + ParameterName = "appointmentSetting", + PropertyNames = new List + { + nameof(ServiceAppointmentSetting.AdditoinalInformation) + } + } +}; ``` + +A white-listed property is normally skipped entirely. If you want to allow *most* content in a property but still block a narrower set of patterns, set an `ExclusionPattern` on the entry. The property is exempt from the global `Pattern`, but the request is still short-circuited when the value matches the `ExclusionPattern`: + +```csharp +cfg.WhiteListEntries = new List +{ + new WhiteListEntry + { + PathTemplate = "api/Services/appointmentSettings/{id}", + ParameterName = "appointmentSetting", + PropertyNames = new List + { + nameof(ServiceAppointmentSetting.AdditoinalInformation) + }, + // Rich text with

, etc. is allowed, but " + } + } + }; + + 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) { 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: "