From 4be328a28db74d8bb588d20d91dc98c58ddd9f9e Mon Sep 17 00:00:00 2001 From: renansj Date: Tue, 2 Jun 2026 15:09:24 -0400 Subject: [PATCH 1/3] redis: add package README and test for AddRedisRateLimiter config --- .../AppRateLimiter.Redis.csproj | 5 ++ src/AppRateLimiter.Redis/README.md | 83 +++++++++++++++++++ .../RedisRateLimitStoreTests.cs | 24 ++++++ 3 files changed, 112 insertions(+) create mode 100644 src/AppRateLimiter.Redis/README.md diff --git a/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj b/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj index 4a98807..c9ff5e9 100644 --- a/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj +++ b/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj @@ -10,9 +10,14 @@ AppRateLimiter Redis-backed distributed store for AppRateLimiter. Runs the whole sliding-window calculation in a single atomic Lua script (race-free across instances), uses the Redis server clock, and sets TTL = 2x window. ratelimit;redis;distributed;aspnetcore;throttling + README.md true + + + + diff --git a/src/AppRateLimiter.Redis/README.md b/src/AppRateLimiter.Redis/README.md new file mode 100644 index 0000000..c999d67 --- /dev/null +++ b/src/AppRateLimiter.Redis/README.md @@ -0,0 +1,83 @@ +# AppRateLimiter.Redis + +Redis-backed distributed store for [AppRateLimiter](https://www.nuget.org/packages/AppRateLimiter). + +Use this when you run more than one instance of your app (for example several Kubernetes or EKS pods). The default in-memory store counts requests per process, so with N replicas the effective limit becomes `limit x N`, and a client load balanced across pods can slip past any single pod's counter. This package keeps one shared counter in Redis so the limit holds globally, no matter which instance serves each request. + +## Install + +```bash +dotnet add package AppRateLimiter.Redis +``` + +This package depends on `AppRateLimiter`, so the core middleware comes with it. + +## Usage + +Register the Redis store instead of `AddAppRateLimiter()`. Everything else (the middleware, the IP and claim rules) works exactly as documented in the core package. + +```csharp +// Connect with a configuration string: +builder.Services.AddRedisRateLimiter("my-redis:6379"); + +// Optionally namespace the keys (defaults to "rl:"): +builder.Services.AddRedisRateLimiter("my-redis:6379", keyPrefix: "rl:prod:"); +``` + +If you already manage your own connection, pass an existing `IConnectionMultiplexer` and the store will reuse it: + +```csharp +var mux = ConnectionMultiplexer.Connect("my-redis:6379"); +builder.Services.AddRedisRateLimiter(mux, keyPrefix: "rl:prod:"); +``` + +Then place the middleware in the pipeline the same way as with the in-memory store: + +```csharp +var app = builder.Build(); + +// IP limiting before authentication. +app.UseRateLimiting(RateLimitRules.ByIp(permitLimit: 100, window: TimeSpan.FromMinutes(1))); + +app.UseAuthentication(); +app.UseAuthorization(); + +// Claim limiting after authentication. +app.UseRateLimiting(RateLimitRules.ByClaim("sub", permitLimit: 1000, window: TimeSpan.FromMinutes(1))); + +app.Run(); +``` + +## How it stays correct and fast + +* **Race free across pods.** The whole sliding window read modify write runs inside a single Lua script, which Redis executes atomically. Concurrent requests from any number of instances cannot race, so there is no over admission. +* **One round trip per check.** The script is cached server side and called via EVALSHA, so each decision is a single pipelined round trip on the shared multiplexer, typically sub millisecond inside the same VPC or AZ. The store is fully async and never blocks thread pool threads. +* **No pod clock skew.** Time comes from the Redis server clock (via `TIME`), not from each pod's clock. +* **Bounded memory.** Each bucket is given a TTL of twice the window (the sliding window counter only needs the current and previous window), so idle keys expire on their own with no manual cleanup. + +## Securing Redis in production + +The rate limit keys hold client IPs and claim values (such as `sub`), so treat the Redis instance as sensitive infrastructure: + +* **Keep it private.** Never expose Redis to the public internet. Put it in a private subnet and allow inbound `6379` only from the app's security group. On EKS, prefer a managed endpoint (such as ElastiCache or MemoryDB) reachable only from the cluster. Leave `protected-mode` on. +* **Require authentication with least privilege.** Use a Redis ACL user limited to the commands this store needs: `EVAL`, `EVALSHA`, `SCRIPT`, `HMGET`, `HSET`, `PEXPIRE`, `TIME`, `PING`. + + ``` + ACL SETUSER ratelimiter on >REPLACE_WITH_STRONG_SECRET ~rl:* +eval +evalsha +script +hmget +hset +pexpire +time +ping + ``` +* **Encrypt in transit.** Enable `ssl=true` with a real hostname, and turn on in transit and at rest encryption on managed Redis. +* **Never hardcode the connection string.** Load it from a secret (a Kubernetes `Secret` or AWS Secrets Manager), not from source: + + ```csharp + builder.Services.AddRedisRateLimiter( + builder.Configuration.GetConnectionString("Redis")!, + keyPrefix: "rl:prod:"); + ``` + + A secured connection string looks like: + `my-redis.internal:6380,ssl=true,user=ratelimiter,password=,abortConnect=false` +* **Namespace the keyspace.** Use a distinct `keyPrefix` per app and environment so multiple services can safely share one cluster while their ACL scope (`~rl:*`) stays contained. + +## Fails closed + +If Redis is unreachable the request errors rather than silently skipping the limit, so an outage cannot be used to bypass rate limiting. diff --git a/tests/AppRateLimiter.Redis.Tests/RedisRateLimitStoreTests.cs b/tests/AppRateLimiter.Redis.Tests/RedisRateLimitStoreTests.cs index 40b918b..af6b191 100644 --- a/tests/AppRateLimiter.Redis.Tests/RedisRateLimitStoreTests.cs +++ b/tests/AppRateLimiter.Redis.Tests/RedisRateLimitStoreTests.cs @@ -1,4 +1,6 @@ using StackExchange.Redis; +using Microsoft.Extensions.DependencyInjection; +using AppRateLimiter; using Xunit; namespace AppRateLimiter.Redis.Tests; @@ -77,4 +79,26 @@ public async Task Concurrency_DoesNotOverAdmit() Assert.Equal(5, results.Count(r => r.Allowed)); } + + // Configuring via AddRedisRateLimiter(connectionString, keyPrefix) registers a working + // store (same path the sample app uses) and the custom keyPrefix is applied to the keys. + [SkippableFact] + public async Task AddRedisRateLimiter_ConfiguresStore_AndAppliesKeyPrefix() + { + Skip.IfNot(_fx.Available, "Redis not reachable at " + _fx.Config); + const string prefix = "cfg-rl:"; + var provider = new ServiceCollection() + .AddRedisRateLimiter(_fx.Config, prefix) + .BuildServiceProvider(); + + var store = provider.GetRequiredService(); + var key = NewKey(); + + for (int i = 0; i < 5; i++) + Assert.True((await store.HitAsync(key, 5, Window, DateTimeOffset.UtcNow)).Allowed); + Assert.False((await store.HitAsync(key, 5, Window, DateTimeOffset.UtcNow)).Allowed); + + // The configured prefix is the one actually used for the Redis key. + Assert.True(_fx.Mux!.GetDatabase().KeyExists(prefix + key)); + } } From 94bd3342fbb01d67ef6f04e88bbef9332b68358d Mon Sep 17 00:00:00 2001 From: renansj Date: Tue, 2 Jun 2026 15:10:43 -0400 Subject: [PATCH 2/3] test: reference Microsoft.Extensions.DependencyInjection for ServiceCollection --- .../AppRateLimiter.Redis.Tests.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj b/tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj index 22fabd2..0b95eb9 100644 --- a/tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj +++ b/tests/AppRateLimiter.Redis.Tests/AppRateLimiter.Redis.Tests.csproj @@ -12,6 +12,8 @@ + + From 9d926e341a950e9dd25a060f85c1582e558e6c61 Mon Sep 17 00:00:00 2001 From: renansj Date: Tue, 2 Jun 2026 15:14:06 -0400 Subject: [PATCH 3/3] redis: bump version to 1.0.1 to publish package with README --- src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj b/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj index c9ff5e9..8c36bf3 100644 --- a/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj +++ b/src/AppRateLimiter.Redis/AppRateLimiter.Redis.csproj @@ -6,7 +6,7 @@ enable AppRateLimiter.Redis - 1.0.0 + 1.0.1 AppRateLimiter Redis-backed distributed store for AppRateLimiter. Runs the whole sliding-window calculation in a single atomic Lua script (race-free across instances), uses the Redis server clock, and sets TTL = 2x window. ratelimit;redis;distributed;aspnetcore;throttling