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);
+ }
+ }
+}