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..2bf2749 --- /dev/null +++ b/tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj @@ -0,0 +1,28 @@ + + + + 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..41d8eaa --- /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(() => 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); + } + } +}