fix: normalize path before rate-limit bucket keying (#27273)

Coder's rate limiter keyed its bucket on the raw, un-normalized request
path (`httprate.KeyByEndpoint` reads `r.URL.Path` directly). The
router's `singleSlashMW` already collapses redundant slashes so a
request like `/api/v2/users//validate-password` reaches the same handler
as the canonical path, but it never touched `r.URL.Path`, so the rate
limiter saw a different key and let a client bypass a limit it had
already hit just by respelling the URL.

`keyByNormalizedEndpoint` replaces `KeyByEndpoint` and runs `path.Clean`
on `r.URL.Path` before using it as the key, so equivalent paths share
one bucket. Includes a unit test at the key-function level and an
integration test (`TestRateLimitPathNormalization`) that reproduces the
bypass against a real server.

Fixes CDM-02-003 (Cure53). Refs
https://github.com/coder/security-disclosures/issues/166.
This commit is contained in:
Bobby Ho
2026-07-15 17:07:57 -07:00
committed by GitHub
parent 0fab0fa0ae
commit de716f89dc
3 changed files with 95 additions and 1 deletions
+48
View File
@@ -612,3 +612,51 @@ func TestRateLimitByUser(t *testing.T) {
"member should not be able to bypass rate limit")
})
}
// TestRateLimitPathNormalization is a regression test for CDM-02-003
// (Cure53): a client could bypass a rate limit by inserting redundant
// slashes into the request path. Coder's router still routes the
// respelled path to the same handler as the canonical path, but the rate
// limiter previously keyed its bucket on the raw, un-normalized path, so
// the respelled request landed in a fresh bucket instead of the one
// already exhausted by the canonical path.
func TestRateLimitPathNormalization(t *testing.T) {
t.Parallel()
const rateLimit = 2
client := coderdtest.New(t, &coderdtest.Options{
LoginRateLimit: rateLimit,
})
ctx := testutil.Context(t, testutil.WaitLong)
post := func(path string) int {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
client.URL.String()+path, strings.NewReader(`{"password":"hunter2"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := client.HTTPClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
return resp.StatusCode
}
// Exhaust the limit against the canonical path.
for i := range rateLimit {
require.Equal(t, http.StatusOK, post("/api/v2/users/validate-password"),
"request %d against the canonical path should succeed", i+1)
}
// The canonical path is now rate limited.
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users/validate-password"),
"canonical path should be rate limited after exhausting the limit")
// Respelling the same endpoint with redundant slashes must not grant a
// fresh bucket: it's the same handler, so it must still be limited.
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users//validate-password"),
"double-slash variant must share the canonical path's rate-limit bucket")
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users///validate-password"),
"triple-slash variant must share the canonical path's rate-limit bucket")
}