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:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user