Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,28 +33,25 @@ 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
sudo apt-get install -y redis-server
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"
Expand All @@ -64,17 +60,33 @@ 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
with:
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
10 changes: 4 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,30 +33,27 @@ 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
sudo apt-get install -y redis-server
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
run: |
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.
Expand Down
2 changes: 2 additions & 0 deletions AppRateLimiter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj" />
<Project Path="src/AppRateLimiter.Web/AppRateLimiter.Web.csproj" />
<Project Path="src/AppRateLimiter/AppRateLimiter.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AppRateLimiter.IntegrationTests/AppRateLimiter.IntegrationTests.csproj" />
<Project Path="tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj" />
<Project Path="tests/AppRateLimiter.Web.Tests/AppRateLimiter.Web.Tests.csproj" />
</Folder>
</Solution>
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
<!-- web.config -->
<system.webServer>
<modules>
<add name="AppRateLimiter" type="AppRateLimiter.Web.RateLimitHttpModule, AppRateLimiter.Web" />
</modules>
</system.webServer>
```

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
Expand Down
6 changes: 6 additions & 0 deletions samples/Sample.NetFramework/Sample.NetFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>

<ItemGroup>
<!-- Build-only: lets net472 compile on Linux runners. No runtime dependency. -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<!-- ASP.NET Core 2.2 is the last version that runs on .NET Framework. -->
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
Expand Down
37 changes: 37 additions & 0 deletions src/AppRateLimiter.Web/AppRateLimiter.Web.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<!-- Allows building this net472 project on non-Windows CI runners. Build-time only. -->
<EnableWindowsTargeting>true</EnableWindowsTargeting>

<PackageId>AppRateLimiter.Web</PackageId>
<Version>1.0.0</Version>
<Authors>AppRateLimiter</Authors>
<Description>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).</Description>
<PackageTags>ratelimit;rate-limiting;system.web;httpmodule;aspnet;iis;throttling</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>

<ItemGroup>
<Reference Include="System.Web" />
</ItemGroup>

<ItemGroup>
<!-- Build-only: lets net472 compile on Linux runners. No runtime dependency. -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AppRateLimiter\AppRateLimiter.csproj" />
<ProjectReference Include="..\AppRateLimiter.Redis\AppRateLimiter.Redis.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

</Project>
3 changes: 3 additions & 0 deletions src/AppRateLimiter.Web/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("AppRateLimiter.Web.Tests")]
86 changes: 86 additions & 0 deletions src/AppRateLimiter.Web/README.md
Original file line number Diff line number Diff line change
@@ -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
<configuration>
<system.webServer>
<modules>
<add name="AppRateLimiter"
type="AppRateLimiter.Web.RateLimitHttpModule, AppRateLimiter.Web" />
</modules>
</system.webServer>
</configuration>
```

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: <seconds>` header
* body `{"error":"rate_limit_exceeded","retryAfterSeconds":<n>}`

## 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)`.
Loading
Loading