From aede00f9007e654913bedf520eb4990b0cfd1add Mon Sep 17 00:00:00 2001 From: renansj Date: Tue, 2 Jun 2026 15:30:33 -0400 Subject: [PATCH 1/2] feat(web): add System.Web (classic ASP.NET) integration via async IHttpModule Additive net472 package AppRateLimiter.Web with RateLimitHttpModule (async, EventHandlerTaskAsyncHelper), WebRateLimitRules ByIp/ByClaim over HttpContextBase, reusing the core store and rules. Exposes IpAddressResolver.NormalizeIp and RateLimitMiddleware.KeySeparator publicly (additive) for parity. Core and Redis stay netstandard2.0 and unchanged behaviorally. Adds Windows CI job for net472 tests. --- .github/workflows/ci.yml | 36 ++++-- .github/workflows/release.yml | 10 +- AppRateLimiter.slnx | 2 + README.md | 42 ++++++- .../Sample.NetFramework.csproj | 6 + .../AppRateLimiter.Web.csproj | 37 ++++++ src/AppRateLimiter.Web/AssemblyInfo.cs | 3 + src/AppRateLimiter.Web/README.md | 86 ++++++++++++++ src/AppRateLimiter.Web/RateLimitHttpModule.cs | 109 ++++++++++++++++++ src/AppRateLimiter.Web/WebRateLimitRule.cs | 29 +++++ src/AppRateLimiter.Web/WebRateLimitRules.cs | 67 +++++++++++ src/AppRateLimiter/IpAddressResolver.cs | 12 ++ src/AppRateLimiter/README.md | 7 +- src/AppRateLimiter/RateLimitMiddleware.cs | 9 +- .../IpNormalizationTests.cs | 26 +++++ .../AppRateLimiter.Web.Tests.csproj | 24 ++++ .../RateLimitHttpModuleTests.cs | 97 ++++++++++++++++ .../WebRateLimitRulesTests.cs | 105 +++++++++++++++++ 18 files changed, 682 insertions(+), 25 deletions(-) create mode 100644 src/AppRateLimiter.Web/AppRateLimiter.Web.csproj create mode 100644 src/AppRateLimiter.Web/AssemblyInfo.cs create mode 100644 src/AppRateLimiter.Web/README.md create mode 100644 src/AppRateLimiter.Web/RateLimitHttpModule.cs create mode 100644 src/AppRateLimiter.Web/WebRateLimitRule.cs create mode 100644 src/AppRateLimiter.Web/WebRateLimitRules.cs create mode 100644 tests/AppRateLimiter.IntegrationTests/IpNormalizationTests.cs create mode 100644 tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj create mode 100644 tests/AppRateLimiter.Web.Tests/RateLimitHttpModuleTests.cs create mode 100644 tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd088ce..9358c98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,11 +10,10 @@ permissions: contents: read packages: write # needed to push prerelease packages to GitHub Packages -# Projects that build/run on a Linux runner. Sample.NetFramework (net472) is -# intentionally excluded: it cannot build on Linux. env: LIB_MAIN: src/AppRateLimiter/AppRateLimiter.csproj LIB_REDIS: src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj + LIB_WEB: src/AppRateLimiter.Web/AppRateLimiter.Web.csproj TEST_INT: tests/AppRateLimiter.IntegrationTests/AppRateLimiter.IntegrationTests.csproj TEST_REDIS: tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj @@ -34,7 +33,6 @@ jobs: # Install Redis from the Ubuntu repos instead of pulling a Docker Hub image, # which is unreliable on hosted runners (anonymous pull rate limits / timeouts). - # This makes the Redis tests run for real without depending on an external registry. - name: Start Redis run: | sudo apt-get update @@ -42,20 +40,18 @@ jobs: sudo systemctl start redis-server || redis-server --daemonize yes redis-cli ping - # Build only the Linux-compatible projects (the two libs + the two test - # projects, which transitively pull in Sample.Api/net10.0). - - name: Restore & build - run: | - dotnet build "$TEST_INT" -c Release - dotnet build "$TEST_REDIS" -c Release + # The net472 projects build on Linux via Microsoft.NETFramework.ReferenceAssemblies. + # The whole solution compiles here (Web library included). + - name: Build solution + run: dotnet build AppRateLimiter.slnx -c Release - # The gate: all tests must pass. Redis is available, so nothing is skipped. + # The gate: all Linux-runnable tests must pass. Redis is available, so nothing is skipped. + # The net472 Web tests run on the separate Windows job below. - name: Test run: | dotnet test "$TEST_INT" -c Release --no-build --verbosity normal dotnet test "$TEST_REDIS" -c Release --no-build --verbosity normal - # Unique prerelease version per PR so GitHub Packages never collides. - name: Compute prerelease version id: ver run: echo "v=1.0.0-pr.${{ github.event.pull_request.number }}.${{ github.run_number }}" >> "$GITHUB_OUTPUT" @@ -64,6 +60,7 @@ jobs: run: | dotnet pack "$LIB_MAIN" -c Release -p:Version=${{ steps.ver.outputs.v }} -o ./artifacts dotnet pack "$LIB_REDIS" -c Release -p:Version=${{ steps.ver.outputs.v }} -o ./artifacts + dotnet pack "$LIB_WEB" -c Release -p:Version=${{ steps.ver.outputs.v }} -o ./artifacts - name: Upload packages as build artifact uses: actions/upload-artifact@v4 @@ -71,10 +68,25 @@ jobs: name: nupkg-${{ steps.ver.outputs.v }} path: ./artifacts/*.nupkg - # Publish the prerelease to GitHub Packages using the native token (no secret needed). - name: Publish prerelease to GitHub Packages run: | dotnet nuget add source --username ${{ github.actor }} --password ${{ secrets.GITHUB_TOKEN }} \ --store-password-in-clear-text --name github \ "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" dotnet nuget push "./artifacts/*.nupkg" --source github --skip-duplicate + + # net472 tests need the real .NET Framework runtime, which only the Windows runner has + # (running them on Linux would require mono). This job covers the System.Web adapter. + web-tests: + runs-on: windows-latest + env: + TEST_WEB: tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Test (System.Web adapter, net472) + run: dotnet test ${{ env.TEST_WEB }} -c Release --verbosity normal diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 182a5f0..1a28b83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ permissions: env: LIB_MAIN: src/AppRateLimiter/AppRateLimiter.csproj LIB_REDIS: src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj + LIB_WEB: src/AppRateLimiter.Web/AppRateLimiter.Web.csproj TEST_INT: tests/AppRateLimiter.IntegrationTests/AppRateLimiter.IntegrationTests.csproj TEST_REDIS: tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj @@ -32,7 +33,6 @@ jobs: with: dotnet-version: '10.0.x' - # Install Redis from the Ubuntu repos (no Docker Hub dependency). - name: Start Redis run: | sudo apt-get update @@ -40,10 +40,8 @@ jobs: sudo systemctl start redis-server || redis-server --daemonize yes redis-cli ping - - name: Build - run: | - dotnet build "$TEST_INT" -c Release - dotnet build "$TEST_REDIS" -c Release + - name: Build solution + run: dotnet build AppRateLimiter.slnx -c Release # Tests must pass before publishing a stable release. - name: Test @@ -51,11 +49,11 @@ jobs: dotnet test "$TEST_INT" -c Release --no-build --verbosity normal dotnet test "$TEST_REDIS" -c Release --no-build --verbosity normal - # Uses the stable Version defined in each csproj (e.g. 1.0.0). - name: Pack (stable) run: | dotnet pack "$LIB_MAIN" -c Release -o ./artifacts dotnet pack "$LIB_REDIS" -c Release -o ./artifacts + dotnet pack "$LIB_WEB" -c Release -o ./artifacts # Publish to nuget.org. --skip-duplicate avoids failing if this version was # already published (NuGet versions are immutable). Bump Version to release anew. diff --git a/AppRateLimiter.slnx b/AppRateLimiter.slnx index 0b04da9..ec08e8e 100644 --- a/AppRateLimiter.slnx +++ b/AppRateLimiter.slnx @@ -5,10 +5,12 @@ + + diff --git a/README.md b/README.md index 6901404..2cbcfa5 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ sample and an integration test suite. ``` src/AppRateLimiter ............ the library (NuGet package, targets netstandard2.0) src/AppRateLimiter.Redis ...... distributed store for multi-instance deployments (Redis) +src/AppRateLimiter.Web ........ classic ASP.NET (System.Web) integration: async IHttpModule (net472) samples/Sample.Api ............ minimal API showing IP + JWT-claim limiting in the right order samples/Sample.NetFramework ... the SAME library on .NET Framework 4.7.2 (ASP.NET Core 2.2) tests/AppRateLimiter.IntegrationTests ....... end-to-end tests over the sample (in-memory) tests/AppRateLimiter.Redis.Tests ............ distributed store tests (need a Redis) +tests/AppRateLimiter.Web.Tests .............. System.Web adapter tests (net472, run on Windows) ``` For library usage and integration instructions, see @@ -36,14 +38,48 @@ dotnet run --project samples/Sample.Api `samples/Sample.NetFramework` proves the legacy story: the **same** library and API (`AddAppRateLimiter` / `UseRateLimiting` / `RateLimitRules`) on **.NET Framework 4.7.2** using ASP.NET Core 2.2's classic `Startup`. Only the host and JWT plumbing differ (on 2.x you clear -`JwtSecurityTokenHandler.DefaultInboundClaimTypeMap` instead of `MapInboundClaims`). Note: this -is ASP.NET Core middleware, so it targets ASP.NET Core apps on Full Framework — not classic -System.Web (WebForms / MVC 5). +`JwtSecurityTokenHandler.DefaultInboundClaimTypeMap` instead of `MapInboundClaims`). This path is +ASP.NET Core middleware on Full Framework. For classic System.Web apps (WebForms / MVC 5 / +Web API 2), use `AppRateLimiter.Web` (see below). ```bash dotnet build samples/Sample.NetFramework # builds a net472 executable ``` +## Classic ASP.NET (System.Web) + +The core is ASP.NET Core middleware, so it does not plug into classic `System.Web` +(WebForms / MVC 5 / Web API 2). That is the most common legacy Windows scenario: an IIS web farm +behind a load balancer. `AppRateLimiter.Web` (net472) closes that gap with an async `IHttpModule` +that applies the same IP and claim rules and reuses the same store, so a farm backed by Redis +shares one global counter. + +Use it when your app runs on the classic System.Web pipeline. Configure it once from +`Global.asax` (classic modules cannot use DI) and register the module in `web.config`: + +```csharp +// Global.asax -> Application_Start +RateLimitHttpModule.Configure( + store: new InMemoryRateLimitStore(), // or RedisRateLimitStore(...) for a web farm + ipRules: new[] { WebRateLimitRules.ByIp(100, TimeSpan.FromMinutes(1)) }, + claimRules: new[] { WebRateLimitRules.ByClaim("sub", 1000, TimeSpan.FromMinutes(1)) }); +``` + +```xml + + + + + + +``` + +IP rules run pre-auth on `BeginRequest`; claim rules run post-auth on `PostAuthenticateRequest`, +reading the validated `HttpContext.User`. Rejections use the same `429` + `Retry-After` + JSON body +as the ASP.NET Core middleware, and all the security invariants (atomic counting, claims from the +validated identity, X-Forwarded-For only behind trusted proxies, IPv6 /64 keying) are preserved. +See [`src/AppRateLimiter.Web/README.md`](src/AppRateLimiter.Web/README.md). + ## Run the tests ```bash diff --git a/samples/Sample.NetFramework/Sample.NetFramework.csproj b/samples/Sample.NetFramework/Sample.NetFramework.csproj index a15509d..dd874cd 100644 --- a/samples/Sample.NetFramework/Sample.NetFramework.csproj +++ b/samples/Sample.NetFramework/Sample.NetFramework.csproj @@ -3,8 +3,14 @@ net472 latest + true + + + + + diff --git a/src/AppRateLimiter.Web/AppRateLimiter.Web.csproj b/src/AppRateLimiter.Web/AppRateLimiter.Web.csproj new file mode 100644 index 0000000..94f2175 --- /dev/null +++ b/src/AppRateLimiter.Web/AppRateLimiter.Web.csproj @@ -0,0 +1,37 @@ + + + + net472 + latest + enable + + true + + AppRateLimiter.Web + 1.0.0 + AppRateLimiter + Classic ASP.NET (System.Web) integration for AppRateLimiter: an async IHttpModule that applies the same IP and claim rate-limit rules to WebForms / MVC 5 / Web API 2 apps on .NET Framework. Reuses the core store (in-memory or distributed Redis). + ratelimit;rate-limiting;system.web;httpmodule;aspnet;iis;throttling + README.md + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AppRateLimiter.Web/AssemblyInfo.cs b/src/AppRateLimiter.Web/AssemblyInfo.cs new file mode 100644 index 0000000..92eed10 --- /dev/null +++ b/src/AppRateLimiter.Web/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AppRateLimiter.Web.Tests")] diff --git a/src/AppRateLimiter.Web/README.md b/src/AppRateLimiter.Web/README.md new file mode 100644 index 0000000..8cc91c1 --- /dev/null +++ b/src/AppRateLimiter.Web/README.md @@ -0,0 +1,86 @@ +# AppRateLimiter.Web + +Classic ASP.NET (System.Web) integration for [AppRateLimiter](https://www.nuget.org/packages/AppRateLimiter). + +The core middleware targets ASP.NET Core, so it covers modern .NET and ASP.NET Core 2.x on .NET Framework. This package adds an async `IHttpModule` for the classic `System.Web` pipeline, which is what WebForms, MVC 5, and Web API 2 use. That is the common legacy Windows scenario: an IIS web farm behind a load balancer. Point the module at the Redis store and every server in the farm shares one global counter. + +## Install + +```bash +dotnet add package AppRateLimiter.Web +``` + +Targets net472. It brings in the core `AppRateLimiter`, and `AppRateLimiter.Redis` for the distributed store. + +## Configure (Global.asax) + +Classic modules cannot use dependency injection, so you supply the store and rules once at startup through a static entry point. + +```csharp +using System; +using AppRateLimiter; +using AppRateLimiter.Redis; +using AppRateLimiter.Web; + +public class Global : System.Web.HttpApplication +{ + protected void Application_Start() + { + // Single server: in-memory store. + IRateLimitStore store = new InMemoryRateLimitStore(); + + // IIS web farm behind a load balancer: shared Redis store instead, so the limit is + // global across all servers. + // IRateLimitStore store = new RedisRateLimitStore( + // StackExchange.Redis.ConnectionMultiplexer.Connect("my-redis:6379"), "rl:"); + + RateLimitHttpModule.Configure( + store, + ipRules: new[] + { + WebRateLimitRules.ByIp(permitLimit: 100, window: TimeSpan.FromMinutes(1)), + }, + claimRules: new[] + { + WebRateLimitRules.ByClaim("sub", permitLimit: 1000, window: TimeSpan.FromMinutes(1)), + }); + } +} +``` + +## Register the module (web.config) + +```xml + + + + + + + +``` + +That is all. IP rules run before authentication on `BeginRequest`, and claim rules run after authentication on `PostAuthenticateRequest`, reading the validated `HttpContext.User`. + +## When a limit is exceeded + +The request short-circuits with the same contract as the ASP.NET Core middleware: + +* `429 Too Many Requests` +* `Retry-After: ` header +* body `{"error":"rate_limit_exceeded","retryAfterSeconds":}` + +## What it preserves + +This adapter keeps the same security properties as the core: + +* **Atomic counting, no over-admission.** It calls the same `IRateLimitStore` (in-memory or the atomic Redis Lua script), and awaits `HitAsync` through `EventHandlerTaskAsyncHelper` rather than blocking a thread. +* **Claims from the validated identity only.** `ByClaim` reads from `HttpContext.User` after authentication and skips unauthenticated requests, so a client cannot point a counter at another principal's bucket. +* **No X-Forwarded-For spoofing.** The client IP comes from the connection. `X-Forwarded-For` is honored only when the direct peer is one of the trusted proxies you pass in, walking the chain right to left and skipping trusted hops. +* **IPv6 rotation contained.** IPv6 clients are keyed by their /64 prefix, and IPv4-mapped addresses fold to plain IPv4, exactly like the core. +* **Same key namespacing.** Rules use the same name based key separator as the core, so when the module and the ASP.NET Core middleware share one store they also share buckets. + +## Authentication note + +Populate `HttpContext.User` with a `ClaimsPrincipal` before `PostAuthenticateRequest` completes (for example via your existing forms/JWT/OWIN authentication). `ByClaim` reads claims with `ClaimsPrincipal.FindFirst(type)`. diff --git a/src/AppRateLimiter.Web/RateLimitHttpModule.cs b/src/AppRateLimiter.Web/RateLimitHttpModule.cs new file mode 100644 index 0000000..02153f3 --- /dev/null +++ b/src/AppRateLimiter.Web/RateLimitHttpModule.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using System.Web; + +namespace AppRateLimiter.Web +{ + /// + /// Async that brings AppRateLimiter to classic ASP.NET + /// (WebForms / MVC 5 / Web API 2 on .NET Framework). IP rules run pre-auth on + /// BeginRequest; claim rules run post-auth on PostAuthenticateRequest, reading + /// the validated . The store and rule sets are supplied once via + /// from Global.asax Application_Start, because classic + /// modules cannot use dependency injection. + /// + /// Register in web.config: + /// <system.webServer><modules><add name="AppRateLimiter" + /// type="AppRateLimiter.Web.RateLimitHttpModule, AppRateLimiter.Web"/></modules></system.webServer> + /// + /// + public sealed class RateLimitHttpModule : IHttpModule + { + private static IRateLimitStore? _store; + private static IReadOnlyList _ipRules = Array.Empty(); + private static IReadOnlyList _claimRules = Array.Empty(); + + /// + /// Supplies the shared store and rule sets. Call once from Application_Start. Use the + /// in-memory store for a single server, or the Redis store (AppRateLimiter.Redis) so an + /// IIS web farm behind a load balancer shares one global counter. + /// + public static void Configure( + IRateLimitStore store, + IEnumerable? ipRules = null, + IEnumerable? claimRules = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _ipRules = ipRules == null ? Array.Empty() : new List(ipRules); + _claimRules = claimRules == null ? Array.Empty() : new List(claimRules); + } + + public void Init(HttpApplication context) + { + // EventHandlerTaskAsyncHelper bridges the Task-based handlers into the classic + // Begin/End async event model, so we await HitAsync without blocking a thread. + var begin = new EventHandlerTaskAsyncHelper(OnBeginRequest); + context.AddOnBeginRequestAsync(begin.BeginEventHandler, begin.EndEventHandler); + + var postAuth = new EventHandlerTaskAsyncHelper(OnPostAuthenticateRequest); + context.AddOnPostAuthenticateRequestAsync(postAuth.BeginEventHandler, postAuth.EndEventHandler); + } + + private static Task OnBeginRequest(object sender, EventArgs e) + => Evaluate(new HttpContextWrapper(((HttpApplication)sender).Context), _ipRules); + + private static Task OnPostAuthenticateRequest(object sender, EventArgs e) + => Evaluate(new HttpContextWrapper(((HttpApplication)sender).Context), _claimRules); + + // Test hook: runs the exact evaluate-and-reject path against a supplied context, using a + // supplied store and rules, without needing an IIS-hosted HttpApplication. Internal so it + // does not widen the public API. Exposed to the test project via InternalsVisibleTo. + internal static Task EvaluateForTest( + IRateLimitStore store, IReadOnlyList rules, HttpContextBase context) + => Evaluate(context, rules, store); + + // Mirrors RateLimitMiddleware.InvokeAsync: first exceeded rule short-circuits with 429. + private static Task Evaluate(HttpContextBase context, IReadOnlyList rules) + => Evaluate(context, rules, _store); + + private static async Task Evaluate(HttpContextBase context, IReadOnlyList rules, IRateLimitStore? store) + { + if (store == null || rules.Count == 0) return; + + DateTimeOffset now = DateTimeOffset.UtcNow; + for (int i = 0; i < rules.Count; i++) + { + WebRateLimitRule rule = rules[i]; + string? key = rule.KeySelector(context); + if (key == null) continue; + + // Same name-based namespacing and separator as the ASP.NET Core middleware, + // so both adapters share buckets when pointed at the same store. + RateLimitResult result = await store + .HitAsync(rule.Name + RateLimitMiddleware.KeySeparator + key, rule.PermitLimit, rule.Window, now) + .ConfigureAwait(false); + if (!result.Allowed) + { + Reject(context, result); + return; + } + } + } + + private static void Reject(HttpContextBase context, RateLimitResult result) + { + int seconds = (int)Math.Ceiling(result.RetryAfter.TotalSeconds); + HttpResponseBase response = context.Response; + response.StatusCode = 429; + response.Headers["Retry-After"] = seconds.ToString(CultureInfo.InvariantCulture); + response.ContentType = "application/json"; + response.Write("{\"error\":\"rate_limit_exceeded\",\"retryAfterSeconds\":" + seconds + "}"); + // Stop the pipeline so no further handlers run for this rejected request. + context.ApplicationInstance?.CompleteRequest(); + } + + public void Dispose() { } + } +} diff --git a/src/AppRateLimiter.Web/WebRateLimitRule.cs b/src/AppRateLimiter.Web/WebRateLimitRule.cs new file mode 100644 index 0000000..b090d6d --- /dev/null +++ b/src/AppRateLimiter.Web/WebRateLimitRule.cs @@ -0,0 +1,29 @@ +using System; +using System.Web; + +namespace AppRateLimiter.Web +{ + /// + /// A single rate-limit policy for classic System.Web. Mirrors the core + /// but selects its key from an . + /// returns the partition key, or null to skip the rule + /// (e.g. claim missing / not authenticated). + /// + public sealed class WebRateLimitRule + { + public string Name { get; } + public int PermitLimit { get; } + public TimeSpan Window { get; } + public Func KeySelector { get; } + + public WebRateLimitRule(string name, int permitLimit, TimeSpan window, Func keySelector) + { + if (permitLimit <= 0) throw new ArgumentOutOfRangeException(nameof(permitLimit)); + if (window <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(window)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + PermitLimit = permitLimit; + Window = window; + KeySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + } + } +} diff --git a/src/AppRateLimiter.Web/WebRateLimitRules.cs b/src/AppRateLimiter.Web/WebRateLimitRules.cs new file mode 100644 index 0000000..1857ff1 --- /dev/null +++ b/src/AppRateLimiter.Web/WebRateLimitRules.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Web; + +namespace AppRateLimiter.Web +{ + /// + /// Built-in rule factories for classic System.Web, mirroring the core + /// . Same keying and security properties; only the context type + /// differs ( instead of ASP.NET Core's HttpContext). + /// + public static class WebRateLimitRules + { + /// Limit by client IP. Apply this BEFORE authentication (BeginRequest). + public static WebRateLimitRule ByIp(int permitLimit, TimeSpan window, + ISet? trustedProxies = null, string name = "ip") + { + return new WebRateLimitRule(name, permitLimit, window, ctx => + { + IPAddress? client = ResolveIp(ctx, trustedProxies); + return client == null ? null : IpAddressResolver.NormalizeIp(client); + }); + } + + /// + /// Limit by a claim. Apply this AFTER authentication (PostAuthenticateRequest) so the + /// claim is taken from the validated and never from + /// client-controlled input (prevents IDOR / impersonation). Unauthenticated requests are + /// skipped and remain covered by the IP rule. + /// + public static WebRateLimitRule ByClaim(string claimType, int permitLimit, TimeSpan window, string? name = null) + { + if (string.IsNullOrEmpty(claimType)) throw new ArgumentNullException(nameof(claimType)); + return new WebRateLimitRule(name ?? "claim:" + claimType, permitLimit, window, ctx => + { + if (ctx.User?.Identity?.IsAuthenticated != true) return null; + string? value = (ctx.User as ClaimsPrincipal)?.FindFirst(claimType)?.Value; + return string.IsNullOrEmpty(value) ? null : value; + }); + } + + // X-Forwarded-For is honored only when the direct peer is a configured trusted proxy; + // we then walk the chain right-to-left skipping trusted hops to find the real client. + private static IPAddress? ResolveIp(HttpContextBase ctx, ISet? trustedProxies) + { + HttpRequestBase request = ctx.Request; + IPAddress? remote = IPAddress.TryParse(request.UserHostAddress, out IPAddress? r) ? r : null; + + if (remote != null && trustedProxies != null && trustedProxies.Count > 0 && trustedProxies.Contains(remote)) + { + string? forwarded = request.Headers["X-Forwarded-For"]; + if (!string.IsNullOrEmpty(forwarded)) + { + string[] hops = forwarded!.Split(','); + for (int i = hops.Length - 1; i >= 0; i--) + { + if (IPAddress.TryParse(hops[i].Trim(), out IPAddress? parsed) && !trustedProxies.Contains(parsed)) + return parsed; + } + } + } + return remote; + } + } +} diff --git a/src/AppRateLimiter/IpAddressResolver.cs b/src/AppRateLimiter/IpAddressResolver.cs index 169d97e..ab805c7 100644 --- a/src/AppRateLimiter/IpAddressResolver.cs +++ b/src/AppRateLimiter/IpAddressResolver.cs @@ -39,6 +39,18 @@ public static class IpAddressResolver } private static string ToKey(IPAddress ip) + { + return NormalizeIp(ip); + } + + /// + /// Normalizes an to a stable rate-limit key: IPv4 stays as-is + /// (/32), IPv4-mapped IPv6 folds to plain IPv4, and other IPv6 addresses collapse to + /// their /64 prefix (so a client cannot rotate within its /64 to dodge the limit). + /// Exposed for adapters outside ASP.NET Core (e.g. classic System.Web) that need the + /// exact same keying as the middleware. + /// + public static string NormalizeIp(IPAddress ip) { byte[] b = ip.GetAddressBytes(); if (b.Length != 16) return ip.ToString(); // IPv4 -> /32 diff --git a/src/AppRateLimiter/README.md b/src/AppRateLimiter/README.md index c6ceba2..dae0b75 100644 --- a/src/AppRateLimiter/README.md +++ b/src/AppRateLimiter/README.md @@ -16,9 +16,10 @@ runtimes through the newest: This is the point: on those older targets there is no built-in limiter (native rate limiting only arrived in .NET 7 via `System.Threading.RateLimiting`). -> It is **ASP.NET Core middleware**, so it plugs into the ASP.NET Core request pipeline. It is -> **not** for classic System.Web apps (WebForms / MVC 5 / Web API 2) — those don't have this -> middleware/`HttpContext` model. +> It is **ASP.NET Core middleware**, so it plugs into the ASP.NET Core request pipeline. For +> classic System.Web apps (WebForms / MVC 5 / Web API 2), which use a different +> middleware/`HttpContext` model, use the companion package **`AppRateLimiter.Web`** (an async +> `IHttpModule` that reuses this same store and rules). --- diff --git a/src/AppRateLimiter/RateLimitMiddleware.cs b/src/AppRateLimiter/RateLimitMiddleware.cs index b7c9dac..471ea98 100644 --- a/src/AppRateLimiter/RateLimitMiddleware.cs +++ b/src/AppRateLimiter/RateLimitMiddleware.cs @@ -13,6 +13,13 @@ namespace AppRateLimiter /// public sealed class RateLimitMiddleware { + /// + /// Separator placed between a rule's name and the request key when building the store + /// key, so different rules cannot collide on the same bucket. Exposed so adapters + /// outside ASP.NET Core (e.g. classic System.Web) build byte-identical keys. + /// + public const string KeySeparator = "\u001f"; + private readonly RequestDelegate _next; private readonly IReadOnlyList _rules; private readonly IRateLimitStore _store; @@ -36,7 +43,7 @@ public async Task InvokeAsync(HttpContext context) // Namespacing by rule name isolates buckets so one rule's keys cannot collide // with another's. RateLimitResult result = await _store - .HitAsync(rule.Name + "\u001f" + key, rule.PermitLimit, rule.Window, now) + .HitAsync(rule.Name + KeySeparator + key, rule.PermitLimit, rule.Window, now) .ConfigureAwait(false); if (!result.Allowed) { diff --git a/tests/AppRateLimiter.IntegrationTests/IpNormalizationTests.cs b/tests/AppRateLimiter.IntegrationTests/IpNormalizationTests.cs new file mode 100644 index 0000000..4f2ac8e --- /dev/null +++ b/tests/AppRateLimiter.IntegrationTests/IpNormalizationTests.cs @@ -0,0 +1,26 @@ +using System.Net; +using AppRateLimiter; +using Xunit; + +namespace AppRateLimiter.IntegrationTests; + +// Covers the public IP normalization helper that adapters (e.g. System.Web) reuse, so its +// behavior stays locked: IPv4 as-is, IPv4-mapped folds to IPv4, IPv6 collapses to /64. +public class IpNormalizationTests +{ + [Theory] + [InlineData("203.0.113.7", "203.0.113.7")] + [InlineData("::ffff:203.0.113.7", "203.0.113.7")] // IPv4-mapped folds to IPv4 + [InlineData("2001:db8:1::1", "2001:db8:1::/64")] + [InlineData("2001:db8:1::abcd", "2001:db8:1::/64")] // same /64 + public void NormalizeIp_ProducesExpectedKey(string input, string expected) + => Assert.Equal(expected, IpAddressResolver.NormalizeIp(IPAddress.Parse(input))); + + [Fact] + public void NormalizeIp_DifferentSlash64_DiffersFromSameSlash64() + { + string a = IpAddressResolver.NormalizeIp(IPAddress.Parse("2001:db8:1::1")); + string b = IpAddressResolver.NormalizeIp(IPAddress.Parse("2001:db8:2::1")); + Assert.NotEqual(a, b); + } +} diff --git a/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj b/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj new file mode 100644 index 0000000..11946d6 --- /dev/null +++ b/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj @@ -0,0 +1,24 @@ + + + + net472 + latest + enable + false + + true + + + + + + + + + + + + + + + diff --git a/tests/AppRateLimiter.Web.Tests/RateLimitHttpModuleTests.cs b/tests/AppRateLimiter.Web.Tests/RateLimitHttpModuleTests.cs new file mode 100644 index 0000000..fefa35f --- /dev/null +++ b/tests/AppRateLimiter.Web.Tests/RateLimitHttpModuleTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using System.Web; +using AppRateLimiter; +using Moq; +using Xunit; + +namespace AppRateLimiter.Web.Tests +{ + public class RateLimitHttpModuleTests + { + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + // Captures what the module writes back to the client. + private sealed class FakeResponse + { + public int StatusCode; + public readonly NameValueCollection Headers = new NameValueCollection(); + public string? ContentType; + public readonly StringBuilder Body = new StringBuilder(); + } + + private static (HttpContextBase Ctx, FakeResponse Resp) Context(string ip) + { + var resp = new FakeResponse(); + + var request = new Mock(); + request.SetupGet(r => r.UserHostAddress).Returns(ip); + request.SetupGet(r => r.Headers).Returns(new NameValueCollection()); + + var response = new Mock(); + response.SetupSet(r => r.StatusCode = It.IsAny()).Callback(v => resp.StatusCode = v); + response.SetupGet(r => r.Headers).Returns(resp.Headers); + response.SetupSet(r => r.ContentType = It.IsAny()).Callback(v => resp.ContentType = v); + response.Setup(r => r.Write(It.IsAny())).Callback(s => resp.Body.Append(s)); + + var ctx = new Mock(); + ctx.SetupGet(c => c.Request).Returns(request.Object); + ctx.SetupGet(c => c.Response).Returns(response.Object); + return (ctx.Object, resp); + } + + // The module admits exactly the limit, then rejects with the same 429 contract as the + // ASP.NET Core middleware (status, Retry-After header, JSON body). + [Fact] + public async Task Module_RejectsAfterLimit_WithRetryAfterAndJsonBody() + { + const string ip = "203.0.113.20"; + const int limit = 3; + var store = new InMemoryRateLimitStore(); + var rules = new List { WebRateLimitRules.ByIp(limit, Window) }; + + for (int i = 0; i < limit; i++) + { + var (ctx, resp) = Context(ip); + await RateLimitHttpModule.EvaluateForTest(store, rules, ctx); + Assert.Equal(0, resp.StatusCode); // not rejected: status untouched + } + + var (blockedCtx, blocked) = Context(ip); + await RateLimitHttpModule.EvaluateForTest(store, rules, blockedCtx); + + Assert.Equal(429, blocked.StatusCode); + Assert.True(int.Parse(blocked.Headers["Retry-After"]) > 0); + Assert.Equal("application/json", blocked.ContentType); + Assert.Contains("\"error\":\"rate_limit_exceeded\"", blocked.Body.ToString()); + Assert.Contains("\"retryAfterSeconds\":", blocked.Body.ToString()); + } + + // Different clients keep independent counters (no shared bucket). + [Fact] + public async Task Module_DifferentClients_AreIndependent() + { + const int limit = 2; + var store = new InMemoryRateLimitStore(); + var rules = new List { WebRateLimitRules.ByIp(limit, Window) }; + + for (int i = 0; i <= limit; i++) + { + var (ctx, _) = Context("203.0.113.21"); + await RateLimitHttpModule.EvaluateForTest(store, rules, ctx); + } + var (c1, r1) = Context("203.0.113.21"); + await RateLimitHttpModule.EvaluateForTest(store, rules, c1); + Assert.Equal(429, r1.StatusCode); + + var (c2, r2) = Context("203.0.113.22"); // different client + await RateLimitHttpModule.EvaluateForTest(store, rules, c2); + Assert.Equal(0, r2.StatusCode); + } + } +} diff --git a/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs b/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs new file mode 100644 index 0000000..bcd5f83 --- /dev/null +++ b/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Security.Claims; +using System.Security.Principal; +using System.Web; +using AppRateLimiter; +using Moq; +using Xunit; + +namespace AppRateLimiter.Web.Tests +{ + public class WebRateLimitRulesTests + { + private static readonly TimeSpan Window = TimeSpan.FromMinutes(1); + + // Builds a faked classic HttpContext with a chosen client IP, optional XFF header, and + // optional authenticated ClaimsPrincipal. + private static HttpContextBase Context(string userHostAddress, string? xff = null, ClaimsPrincipal? user = null) + { + var headers = new NameValueCollection(); + if (xff != null) headers["X-Forwarded-For"] = xff; + + var request = new Mock(); + request.SetupGet(r => r.UserHostAddress).Returns(userHostAddress); + request.SetupGet(r => r.Headers).Returns(headers); + + var ctx = new Mock(); + ctx.SetupGet(c => c.Request).Returns(request.Object); + ctx.SetupGet(c => c.User).Returns((IPrincipal?)user); + return ctx.Object; + } + + private static ClaimsPrincipal Authenticated(params Claim[] claims) + => new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test")); + + // ByIp produces exactly the same key the core resolver would for the same address. + [Theory] + [InlineData("203.0.113.7")] + [InlineData("2001:db8:1::1")] + public void ByIp_KeyMatchesCoreNormalization(string ip) + { + var rule = WebRateLimitRules.ByIp(10, Window); + string? key = rule.KeySelector(Context(ip)); + Assert.Equal(IpAddressResolver.NormalizeIp(IPAddress.Parse(ip)), key); + } + + // Two addresses in the same IPv6 /64 collapse to one key; a different /64 does not. + [Fact] + public void ByIp_IPv6_SameSlash64_SharesKey() + { + var rule = WebRateLimitRules.ByIp(10, Window); + string? a = rule.KeySelector(Context("2001:db8:1::1")); + string? b = rule.KeySelector(Context("2001:db8:1::2")); + string? c = rule.KeySelector(Context("2001:db8:2::1")); + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } + + // X-Forwarded-For is ignored unless the direct peer is a configured trusted proxy. + [Fact] + public void ByIp_Xff_IgnoredWhenPeerNotTrusted() + { + var rule = WebRateLimitRules.ByIp(10, Window); // no trusted proxies + string? key = rule.KeySelector(Context("10.0.0.9", xff: "203.0.113.7")); + Assert.Equal(IpAddressResolver.NormalizeIp(IPAddress.Parse("10.0.0.9")), key); + } + + // Behind a trusted proxy, the real client is taken from XFF (right-to-left, skip trusted). + [Fact] + public void ByIp_Xff_HonoredBehindTrustedProxy() + { + var trusted = new HashSet { IPAddress.Parse("10.0.0.9") }; + var rule = WebRateLimitRules.ByIp(10, Window, trusted); + string? key = rule.KeySelector(Context("10.0.0.9", xff: "203.0.113.7, 10.0.0.9")); + Assert.Equal(IpAddressResolver.NormalizeIp(IPAddress.Parse("203.0.113.7")), key); + } + + // ByClaim skips unauthenticated requests (no key), so anonymous traffic is left to ByIp. + [Fact] + public void ByClaim_SkipsWhenNotAuthenticated() + { + var rule = WebRateLimitRules.ByClaim("sub", 10, Window); + Assert.Null(rule.KeySelector(Context("203.0.113.7"))); + } + + // ByClaim reads the value from the validated principal. + [Fact] + public void ByClaim_UsesValidatedClaimValue() + { + var rule = WebRateLimitRules.ByClaim("sub", 10, Window); + var user = Authenticated(new Claim("sub", "user-a")); + Assert.Equal("user-a", rule.KeySelector(Context("203.0.113.7", user: user))); + } + + // Default rule names match the core conventions ("ip" and "claim:"). + [Fact] + public void DefaultRuleNames_MatchCore() + { + Assert.Equal("ip", WebRateLimitRules.ByIp(1, Window).Name); + Assert.Equal("claim:sub", WebRateLimitRules.ByClaim("sub", 1, Window).Name); + } + } +} From 12bb5dbfbd5b76eb9f455a577279b2c94d17a482 Mon Sep 17 00:00:00 2001 From: renansj Date: Tue, 2 Jun 2026 15:34:12 -0400 Subject: [PATCH 2/2] test(web): reference System.Web so the test project builds on Linux; fix nullable warnings --- .../AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj | 4 ++++ tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj b/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj index 11946d6..2bf2749 100644 --- a/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj +++ b/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj @@ -9,6 +9,10 @@ true + + + + diff --git a/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs b/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs index bcd5f83..41d8eaa 100644 --- a/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs +++ b/tests/AppRateLimiter.Web.Tests/WebRateLimitRulesTests.cs @@ -28,7 +28,7 @@ private static HttpContextBase Context(string userHostAddress, string? xff = nul var ctx = new Mock(); ctx.SetupGet(c => c.Request).Returns(request.Object); - ctx.SetupGet(c => c.User).Returns((IPrincipal?)user); + ctx.SetupGet(c => c.User).Returns(() => user!); return ctx.Object; }