Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ac2323f

Browse files
fix: normalize path before rate-limit bucket keying (#27273) (#27444)
Backport of #27273 Original PR: #27273 — fix: normalize path before rate-limit bucket keying Merge commit: de716f8 Requested by: @jdomeracki-coder Co-authored-by: Bobby Ho <[email protected]> Co-authored-by: Bobby Ho <[email protected]>
1 parent b649e7d commit ac2323f

3 files changed

Lines changed: 95 additions & 1 deletion

File tree

coderd/coderd_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,51 @@ func TestRateLimitByUser(t *testing.T) {
523523
"member should not be able to bypass rate limit")
524524
})
525525
}
526+
527+
// TestRateLimitPathNormalization is a regression test for CDM-02-003
528+
// (Cure53): a client could bypass a rate limit by inserting redundant
529+
// slashes into the request path. Coder's router still routes the
530+
// respelled path to the same handler as the canonical path, but the rate
531+
// limiter previously keyed its bucket on the raw, un-normalized path, so
532+
// the respelled request landed in a fresh bucket instead of the one
533+
// already exhausted by the canonical path.
534+
func TestRateLimitPathNormalization(t *testing.T) {
535+
t.Parallel()
536+
537+
const rateLimit = 2
538+
539+
client := coderdtest.New(t, &coderdtest.Options{
540+
LoginRateLimit: rateLimit,
541+
})
542+
543+
ctx := testutil.Context(t, testutil.WaitLong)
544+
545+
post := func(path string) int {
546+
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
547+
client.URL.String()+path, strings.NewReader(`{"password":"hunter2"}`))
548+
require.NoError(t, err)
549+
req.Header.Set("Content-Type", "application/json")
550+
551+
resp, err := client.HTTPClient.Do(req)
552+
require.NoError(t, err)
553+
defer resp.Body.Close()
554+
return resp.StatusCode
555+
}
556+
557+
// Exhaust the limit against the canonical path.
558+
for i := range rateLimit {
559+
require.Equal(t, http.StatusOK, post("/api/v2/users/validate-password"),
560+
"request %d against the canonical path should succeed", i+1)
561+
}
562+
563+
// The canonical path is now rate limited.
564+
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users/validate-password"),
565+
"canonical path should be rate limited after exhausting the limit")
566+
567+
// Respelling the same endpoint with redundant slashes must not grant a
568+
// fresh bucket: it's the same handler, so it must still be limited.
569+
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users//validate-password"),
570+
"double-slash variant must share the canonical path's rate-limit bucket")
571+
require.Equal(t, http.StatusTooManyRequests, post("/api/v2/users///validate-password"),
572+
"triple-slash variant must share the canonical path's rate-limit bucket")
573+
}

coderd/httpmw/ratelimit.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package httpmw
33
import (
44
"fmt"
55
"net/http"
6+
"path"
67
"strconv"
78
"sync/atomic"
89
"time"
@@ -85,7 +86,7 @@ func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler
8586
"%q provided but user is not %v",
8687
codersdk.BypassRatelimitHeader, rbac.RoleOwner(),
8788
)
88-
}, httprate.KeyByEndpoint),
89+
}, keyByNormalizedEndpoint),
8990
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
9091
httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{
9192
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
9495
)
9596
}
9697

98+
// keyByNormalizedEndpoint mirrors httprate.KeyByEndpoint, but cleans the
99+
// request path first. chi's router tolerates redundant slashes (see
100+
// singleSlashMW in coderd.go) and routes them to the same handler as the
101+
// canonical path, but only normalizes its internal route-matching path,
102+
// not r.URL.Path. Without normalizing here too, a client can respell a
103+
// path, for example inserting an extra slash, to get a fresh rate-limit
104+
// bucket for an endpoint it's already been throttled on.
105+
func keyByNormalizedEndpoint(r *http.Request) (string, error) {
106+
p := r.URL.Path
107+
if p == "" {
108+
p = "/"
109+
}
110+
return path.Clean(p), nil
111+
}
112+
97113
// RateLimitByAuthToken returns a handler that limits requests based on the
98114
// authentication token in the request.
99115
//

coderd/httpmw/ratelimit_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,36 @@ func TestRateLimit(t *testing.T) {
4949
}
5050
})
5151

52+
t.Run("PathNormalizationBypass", func(t *testing.T) {
53+
t.Parallel()
54+
rtr := chi.NewRouter()
55+
rtr.Use(httpmw.RateLimit(1, time.Second))
56+
// A wildcard route so that requests for both the canonical path and
57+
// its redundant-slash variants reach the same handler, mirroring
58+
// how chi's router resolves /api/v2/users//validate-password to the
59+
// same handler as /api/v2/users/validate-password in production.
60+
rtr.Post("/*", func(rw http.ResponseWriter, r *http.Request) {
61+
rw.WriteHeader(http.StatusOK)
62+
})
63+
64+
remoteAddr := randRemoteAddr()
65+
paths := []string{
66+
"/api/v2/users/validate-password",
67+
"/api/v2/users//validate-password",
68+
"/api/v2/users///validate-password",
69+
"/api/v2/users/validate-password",
70+
}
71+
for i, p := range paths {
72+
req := httptest.NewRequest("POST", p, nil)
73+
req.RemoteAddr = remoteAddr
74+
rec := httptest.NewRecorder()
75+
rtr.ServeHTTP(rec, req)
76+
resp := rec.Result()
77+
_ = resp.Body.Close()
78+
require.Equal(t, i != 0, resp.StatusCode == http.StatusTooManyRequests, "request %d (%s)", i, p)
79+
}
80+
})
81+
5282
t.Run("RandomIPs", func(t *testing.T) {
5383
t.Parallel()
5484
rtr := chi.NewRouter()

0 commit comments

Comments
 (0)