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
+17 -1
View File
@@ -3,6 +3,7 @@ package httpmw
import (
"fmt"
"net/http"
"path"
"strconv"
"sync/atomic"
"time"
@@ -85,7 +86,7 @@ func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler
"%q provided but user is not %v",
codersdk.BypassRatelimitHeader, rbac.RoleOwner(),
)
}, httprate.KeyByEndpoint),
}, keyByNormalizedEndpoint),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{
Message: fmt.Sprintf("You've been rate limited for sending more than %v requests in %v.", count, window),
@@ -94,6 +95,21 @@ func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler
)
}
// keyByNormalizedEndpoint mirrors httprate.KeyByEndpoint, but cleans the
// request path first. chi's router tolerates redundant slashes (see
// singleSlashMW in coderd.go) and routes them to the same handler as the
// canonical path, but only normalizes its internal route-matching path,
// not r.URL.Path. Without normalizing here too, a client can respell a
// path, for example inserting an extra slash, to get a fresh rate-limit
// bucket for an endpoint it's already been throttled on.
func keyByNormalizedEndpoint(r *http.Request) (string, error) {
p := r.URL.Path
if p == "" {
p = "/"
}
return path.Clean(p), nil
}
// RateLimitByAuthToken returns a handler that limits requests based on the
// authentication token in the request.
//
+30
View File
@@ -49,6 +49,36 @@ func TestRateLimit(t *testing.T) {
}
})
t.Run("PathNormalizationBypass", func(t *testing.T) {
t.Parallel()
rtr := chi.NewRouter()
rtr.Use(httpmw.RateLimit(1, time.Second))
// A wildcard route so that requests for both the canonical path and
// its redundant-slash variants reach the same handler, mirroring
// how chi's router resolves /api/v2/users//validate-password to the
// same handler as /api/v2/users/validate-password in production.
rtr.Post("/*", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
})
remoteAddr := randRemoteAddr()
paths := []string{
"/api/v2/users/validate-password",
"/api/v2/users//validate-password",
"/api/v2/users///validate-password",
"/api/v2/users/validate-password",
}
for i, p := range paths {
req := httptest.NewRequest("POST", p, nil)
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
rtr.ServeHTTP(rec, req)
resp := rec.Result()
_ = resp.Body.Close()
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, "request %d (%s)", i, p)
}
})
t.Run("RandomIPs", func(t *testing.T) {
t.Parallel()
rtr := chi.NewRouter()