mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user