Strapi's rate limiter blocked /admin/login after 5 failed attempts. /Admin/login kept accepting requests. The router normalized the case before matching; the rate limiter keyed on the raw string and issued a fresh counter for each variant.
That is the core bypass mechanism: rate limiters receive the path before any framework normalization. Routers normalize before matching, converting to lowercase, collapsing double slashes, and decoding percent-encoding. The rate limiter never sees the canonical path. Any divergence between the raw string and the normalized string is a bypass surface. That surface survives correct IP handling and correct header trust configuration.
Rate Limiters and Routers Process the Same Path Independently
Rate limiting middleware executes before path normalization. The key tracking request counts is the raw string the client sends, not the canonical path the router eventually matches. That difference is structural, not accidental.
CVE-2023-38507 (Strapi <= 4.12.0, CVSS 5.3) documents this pattern precisely. The Koa router normalized paths before route matching but did not update ctx.request.path after normalization. The rate limiter read ctx.request.path literally, generating distinct counters for /admin/login and /Admin/login. Both paths reached the same authentication handler with entirely separate attempt budgets.
The Strapi fix did not change the router. It normalized the path inside the rate limit key construction function, forcing lowercase and stripping trailing slashes before computing the hash. The router and the rate limiter then agreed on the path value.
Case Mutation Multiplies the Attempt Budget
In any rate limiter keying on the raw path string without case normalization, a path with k alphabetic characters admits 2^k distinct buckets. All buckets point at the same handler. The configured threshold becomes a per-permutation limit, not a per-endpoint limit.
CVE-2023-38507 confirms this in production. The paths /admin/login, /Admin/login, /ADMIN/LOGIN, and /admin/login/ generated independent counters against the same Strapi authentication handler. The vendor confirmed in its September 2023 disclosure that each variant received a separate budget.
The direct calculation: rate limit of 5 attempts per minute with a path of 5 alphabetic characters. The attacker has 5 x 2^5 = 160 attempts per minute without triggering any individual counter. The threshold of 5 survives intact across all 32 buckets.
Double-Slash Normalization: The Router Collapses What the Limiter Counts as a New Key
GHSA-x732-6j76-qmhm (Better Auth < 1.4.4, CVSS 8.6) documents the double-slash variant. The rou3 router collapsed empty path segments, routing //sign-in/email and ///sign-in/email identically to /sign-in/email. The upstream rate limiter saw 3 distinct path strings and issued 3 independent counters against the same authentication endpoint.
The attack technique is direct: rotate between /login, //login, and ///login in sequence. Each variant consumes a fresh counter and routes to the same handler. The effective attempt budget multiplies by the number of slash prefixes the server accepts before rejecting the path.
The fix landed in rou3 commit f60b43f, which stopped silently collapsing duplicate segments. Better Auth updated to v1.4.5 with that dependency. The preventive alternative: normalize URLs in a pre-middleware layer before the rate limiter executes, so the limiter never sees the un-normalized variant.
Percent-Encoding Generates 3 Distinct Path Representations Across 3 Layers
CVE-2024-1019 (ModSecurity 3.0.0-3.0.11, CVSS 8.6) documents the most subtle layer. ModSecurity v3 decoded percent-encoded characters before splitting path from query string. The %3f character (encoded ?) caused the payload to be treated as a query string. That bypassed path-based inspection rules keyed on REQUEST_FILENAME, including rate limiters bound to that variable.
The structural problem spans 3 layers with distinct decoding behaviors. The rate limiter sees the raw string. The framework decodes once. The route matcher normalizes and matches. /L%6fgin and /login are identical to the router but distinct strings to a rate limiter keying on the raw representation.
Double-encoding adds another layer. %252f decoded by the framework produces %2f; a second decoding pass produces /. The rate limiter sees %252f as a literal character string, a completely different key from the normalized path the router receives.
Shared API Keys Turn Per-Key Rate Limits Into Collective Budgets
A documented pattern in stateful rate limiters is the horizontal scaling problem. Rate limit counters stored in memory per process rather than in shared state mean that with N replicas behind a load balancer, each replica maintains its own counter, making the effective aggregate rate limit N times the configured value.
The problem extends beyond replicas. Public SDKs embedding a shared API key place the rate limit on a collective pool. A heavy caller exhausts the budget for all users of that key without exceeding any per-account threshold. Anyone with access to the key can exhaust the total budget legitimately or maliciously.
The structural root: the rate limit applies to the key, not the caller. Rate limits on shared keys are service limits any key holder can exhaust. They are denial-of-service surfaces accessible without additional authentication.
Defense: Normalize Before Computing the Key
The root cause of all path-variation bypasses is the same: the key is computed before normalization. Applying the same transformations the router applies before constructing the key closes all vectors simultaneously. The required transformations: lowercase, collapse //, decode percent-encoding, strip trailing slash.
In Express, req.path is already normalized by the framework. Use req.path as the rate limit key, never req.url, which contains the query string and pre-normalization path. In Nginx, $uri is normalized (decoded, slashes normalized); $request_uri is raw. Use $uri as the key in limit_req_zone.
For per-replica state: move counters to Redis shared across all replicas. In-memory state breaks under horizontal scaling. For shared API keys: key rate limits on authenticated user identity, not on a credential shared across callers. The MAGO Intel tool (intel.mago.team) fuzzes rate-limited endpoints with case, double-slash, and percent-encoding variants to identify which path representations receive independent counters.
The bypass closes when the rate limiter and the router agree on what the path is. Check in code review how the rate limit key is constructed. It must apply the same transformations the router applies to the same string.
Top comments (0)