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

Skip to content

Commit 423ac04

Browse files
authored
coderd: tighten /login rate limiting (coder#4432)
* coderd: tighten /login rate limit * coderd: add Bypass rate limit header
1 parent 43f199a commit 423ac04

File tree

6 files changed

+211
-20
lines changed

6 files changed

+211
-20
lines changed

.golangci.yaml

+8-4
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,15 @@ linters:
235235
- noctx
236236
- paralleltest
237237
- revive
238-
- rowserrcheck
239-
- sqlclosecheck
238+
239+
# These don't work until the following issue is solved.
240+
# https://github.com/golangci/golangci-lint/issues/2649
241+
# - rowserrcheck
242+
# - sqlclosecheck
243+
# - structcheck
244+
# - wastedassign
245+
240246
- staticcheck
241-
- structcheck
242247
- tenv
243248
# In Go, it's possible for a package to test it's internal functionality
244249
# without testing any exported functions. This is enabled to promote
@@ -253,4 +258,3 @@ linters:
253258
- unconvert
254259
- unused
255260
- varcheck
256-
- wastedassign

coderd/coderd.go

+13-5
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func New(options *Options) *API {
204204
// app URL. If it is, it will serve that application.
205205
api.handleSubdomainApplications(
206206
// Middleware to impose on the served application.
207-
httpmw.RateLimitPerMinute(options.APIRateLimit),
207+
httpmw.RateLimit(options.APIRateLimit, time.Minute),
208208
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
209209
DB: options.Database,
210210
OAuth2Configs: oauthConfigs,
@@ -229,7 +229,7 @@ func New(options *Options) *API {
229229
apps := func(r chi.Router) {
230230
r.Use(
231231
tracing.Middleware(api.TracerProvider),
232-
httpmw.RateLimitPerMinute(options.APIRateLimit),
232+
httpmw.RateLimit(options.APIRateLimit, time.Minute),
233233
httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
234234
DB: options.Database,
235235
OAuth2Configs: oauthConfigs,
@@ -267,7 +267,7 @@ func New(options *Options) *API {
267267
r.Use(
268268
tracing.Middleware(api.TracerProvider),
269269
// Specific routes can specify smaller limits.
270-
httpmw.RateLimitPerMinute(options.APIRateLimit),
270+
httpmw.RateLimit(options.APIRateLimit, time.Minute),
271271
)
272272
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
273273
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Response{
@@ -304,7 +304,7 @@ func New(options *Options) *API {
304304
apiKeyMiddleware,
305305
// This number is arbitrary, but reading/writing
306306
// file content is expensive so it should be small.
307-
httpmw.RateLimitPerMinute(12),
307+
httpmw.RateLimit(12, time.Minute),
308308
)
309309
r.Get("/{fileID}", api.fileByID)
310310
r.Post("/", api.postFile)
@@ -391,7 +391,15 @@ func New(options *Options) *API {
391391
r.Route("/users", func(r chi.Router) {
392392
r.Get("/first", api.firstUser)
393393
r.Post("/first", api.postFirstUser)
394-
r.Post("/login", api.postLogin)
394+
r.Group(func(r chi.Router) {
395+
// We use a tight limit for password login to protect
396+
// against audit-log write DoS, pbkdf2 DoS, and simple
397+
// brute-force attacks.
398+
//
399+
// Making this too small can break tests.
400+
r.Use(httpmw.RateLimit(60, time.Minute))
401+
r.Post("/login", api.postLogin)
402+
})
395403
r.Get("/authmethods", api.userAuthMethods)
396404
r.Route("/oauth2", func(r chi.Router) {
397405
r.Route("/github", func(r chi.Router) {

coderd/httpmw/apikey_test.go

+7-3
Original file line numberDiff line numberDiff line change
@@ -631,16 +631,20 @@ func TestAPIKey(t *testing.T) {
631631
})
632632
}
633633

634-
func createUser(ctx context.Context, t *testing.T, db database.Store) database.User {
635-
user, err := db.InsertUser(ctx, database.InsertUserParams{
634+
func createUser(ctx context.Context, t *testing.T, db database.Store, opts ...func(u *database.InsertUserParams)) database.User {
635+
insert := database.InsertUserParams{
636636
ID: uuid.New(),
637637
638638
Username: "username",
639639
HashedPassword: []byte{},
640640
CreatedAt: time.Now(),
641641
UpdatedAt: time.Now(),
642642
RBACRoles: []string{},
643-
})
643+
}
644+
for _, opt := range opts {
645+
opt(&insert)
646+
}
647+
user, err := db.InsertUser(ctx, insert)
644648
require.NoError(t, err, "create user")
645649
return user
646650
}

coderd/httpmw/ratelimit.go

+38-6
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,71 @@
11
package httpmw
22

33
import (
4+
"fmt"
45
"net/http"
6+
"strconv"
57
"time"
68

79
"github.com/go-chi/httprate"
10+
"golang.org/x/xerrors"
811

912
"github.com/coder/coder/coderd/database"
1013
"github.com/coder/coder/coderd/httpapi"
14+
"github.com/coder/coder/coderd/rbac"
1115
"github.com/coder/coder/codersdk"
16+
"github.com/coder/coder/cryptorand"
1217
)
1318

14-
// RateLimitPerMinute returns a handler that limits requests per-minute based
19+
// RateLimit returns a handler that limits requests per-minute based
1520
// on IP, endpoint, and user ID (if available).
16-
func RateLimitPerMinute(count int) func(http.Handler) http.Handler {
21+
func RateLimit(count int, window time.Duration) func(http.Handler) http.Handler {
1722
// -1 is no rate limit
1823
if count <= 0 {
1924
return func(handler http.Handler) http.Handler {
2025
return handler
2126
}
2227
}
28+
2329
return httprate.Limit(
2430
count,
25-
1*time.Minute,
31+
window,
2632
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
2733
// Prioritize by user, but fallback to IP.
2834
apiKey, ok := r.Context().Value(apiKeyContextKey{}).(database.APIKey)
29-
if ok {
35+
if !ok {
36+
return httprate.KeyByIP(r)
37+
}
38+
39+
if ok, _ := strconv.ParseBool(r.Header.Get(codersdk.BypassRatelimitHeader)); !ok {
40+
// No bypass attempt, just ratelimit.
3041
return apiKey.UserID.String(), nil
3142
}
32-
return httprate.KeyByIP(r)
43+
44+
// Allow Owner to bypass rate limiting for load tests
45+
// and automation.
46+
auth := UserAuthorization(r)
47+
48+
// We avoid using rbac.Authorizer since rego is CPU-intensive
49+
// and undermines the DoS-prevention goal of the rate limiter.
50+
for _, role := range auth.Roles {
51+
if role == rbac.RoleOwner() {
52+
// HACK: use a random key each time to
53+
// de facto disable rate limiting. The
54+
// `httprate` package has no
55+
// support for selectively changing the limit
56+
// for particular keys.
57+
return cryptorand.String(16)
58+
}
59+
}
60+
61+
return apiKey.UserID.String(), xerrors.Errorf(
62+
"%q provided but user is not %v",
63+
codersdk.BypassRatelimitHeader, rbac.RoleOwner(),
64+
)
3365
}, httprate.KeyByEndpoint),
3466
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
3567
httpapi.Write(r.Context(), w, http.StatusTooManyRequests, codersdk.Response{
36-
Message: "You've been rate limited for sending too many requests!",
68+
Message: fmt.Sprintf("You've been rate limited for sending more than %v requests in %v.", count, window),
3769
})
3870
}),
3971
)

coderd/httpmw/ratelimit_test.go

+142-2
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,170 @@
11
package httpmw_test
22

33
import (
4+
"context"
5+
"crypto/sha256"
6+
"fmt"
7+
"math/rand"
8+
"net"
49
"net/http"
510
"net/http/httptest"
611
"testing"
12+
"time"
713

814
"github.com/go-chi/chi/v5"
15+
"github.com/google/uuid"
916
"github.com/stretchr/testify/require"
1017

18+
"github.com/coder/coder/coderd/database"
19+
"github.com/coder/coder/coderd/database/databasefake"
1120
"github.com/coder/coder/coderd/httpmw"
21+
"github.com/coder/coder/coderd/rbac"
22+
"github.com/coder/coder/codersdk"
1223
"github.com/coder/coder/testutil"
1324
)
1425

26+
func insertAPIKey(ctx context.Context, t *testing.T, db database.Store, userID uuid.UUID) string {
27+
id, secret := randomAPIKeyParts()
28+
hashed := sha256.Sum256([]byte(secret))
29+
30+
_, err := db.InsertAPIKey(ctx, database.InsertAPIKeyParams{
31+
ID: id,
32+
HashedSecret: hashed[:],
33+
LastUsed: database.Now().AddDate(0, 0, -1),
34+
ExpiresAt: database.Now().AddDate(0, 0, 1),
35+
UserID: userID,
36+
LoginType: database.LoginTypePassword,
37+
Scope: database.APIKeyScopeAll,
38+
})
39+
require.NoError(t, err)
40+
41+
return fmt.Sprintf("%s-%s", id, secret)
42+
}
43+
44+
func randRemoteAddr() string {
45+
var b [4]byte
46+
// nolint:gosec
47+
rand.Read(b[:])
48+
// nolint:gosec
49+
return fmt.Sprintf("%s:%v", net.IP(b[:]).String(), rand.Int31()%(1<<16))
50+
}
51+
1552
func TestRateLimit(t *testing.T) {
1653
t.Parallel()
17-
t.Run("NoUser", func(t *testing.T) {
54+
t.Run("NoUserSucceeds", func(t *testing.T) {
55+
t.Parallel()
56+
rtr := chi.NewRouter()
57+
rtr.Use(httpmw.RateLimit(5, time.Second))
58+
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
59+
rw.WriteHeader(http.StatusOK)
60+
})
61+
62+
require.Eventually(t, func() bool {
63+
req := httptest.NewRequest("GET", "/", nil)
64+
rec := httptest.NewRecorder()
65+
rtr.ServeHTTP(rec, req)
66+
resp := rec.Result()
67+
defer resp.Body.Close()
68+
return resp.StatusCode == http.StatusTooManyRequests
69+
}, testutil.WaitShort, testutil.IntervalFast)
70+
})
71+
72+
t.Run("RandomIPs", func(t *testing.T) {
73+
t.Parallel()
74+
rtr := chi.NewRouter()
75+
rtr.Use(httpmw.RateLimit(5, time.Second))
76+
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
77+
rw.WriteHeader(http.StatusOK)
78+
})
79+
80+
require.Never(t, func() bool {
81+
req := httptest.NewRequest("GET", "/", nil)
82+
rec := httptest.NewRecorder()
83+
req.RemoteAddr = randRemoteAddr()
84+
rtr.ServeHTTP(rec, req)
85+
resp := rec.Result()
86+
defer resp.Body.Close()
87+
return resp.StatusCode == http.StatusTooManyRequests
88+
}, testutil.WaitShort, testutil.IntervalFast)
89+
})
90+
91+
t.Run("RegularUser", func(t *testing.T) {
1892
t.Parallel()
93+
94+
ctx := context.Background()
95+
96+
db := databasefake.New()
97+
98+
u := createUser(ctx, t, db)
99+
key := insertAPIKey(ctx, t, db, u.ID)
100+
19101
rtr := chi.NewRouter()
20-
rtr.Use(httpmw.RateLimitPerMinute(5))
102+
rtr.Use(httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
103+
DB: db,
104+
Optional: false,
105+
}))
106+
107+
rtr.Use(httpmw.RateLimit(5, time.Second))
21108
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
22109
rw.WriteHeader(http.StatusOK)
23110
})
24111

112+
// Bypass must fail
113+
req := httptest.NewRequest("GET", "/", nil)
114+
req.Header.Set(codersdk.SessionCustomHeader, key)
115+
req.Header.Set(codersdk.BypassRatelimitHeader, "true")
116+
rec := httptest.NewRecorder()
117+
// Assert we're not using IP address.
118+
req.RemoteAddr = randRemoteAddr()
119+
rtr.ServeHTTP(rec, req)
120+
resp := rec.Result()
121+
defer resp.Body.Close()
122+
require.Equal(t, http.StatusPreconditionRequired, resp.StatusCode)
123+
25124
require.Eventually(t, func() bool {
26125
req := httptest.NewRequest("GET", "/", nil)
126+
req.Header.Set(codersdk.SessionCustomHeader, key)
127+
rec := httptest.NewRecorder()
128+
// Assert we're not using IP address.
129+
req.RemoteAddr = randRemoteAddr()
130+
rtr.ServeHTTP(rec, req)
131+
resp := rec.Result()
132+
defer resp.Body.Close()
133+
return resp.StatusCode == http.StatusTooManyRequests
134+
}, testutil.WaitShort, testutil.IntervalFast)
135+
})
136+
137+
t.Run("OwnerBypass", func(t *testing.T) {
138+
t.Parallel()
139+
140+
ctx := context.Background()
141+
142+
db := databasefake.New()
143+
144+
u := createUser(ctx, t, db, func(u *database.InsertUserParams) {
145+
u.RBACRoles = []string{rbac.RoleOwner()}
146+
})
147+
148+
key := insertAPIKey(ctx, t, db, u.ID)
149+
150+
rtr := chi.NewRouter()
151+
rtr.Use(httpmw.ExtractAPIKey(httpmw.ExtractAPIKeyConfig{
152+
DB: db,
153+
Optional: false,
154+
}))
155+
156+
rtr.Use(httpmw.RateLimit(5, time.Second))
157+
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
158+
rw.WriteHeader(http.StatusOK)
159+
})
160+
161+
require.Never(t, func() bool {
162+
req := httptest.NewRequest("GET", "/", nil)
163+
req.Header.Set(codersdk.SessionCustomHeader, key)
164+
req.Header.Set(codersdk.BypassRatelimitHeader, "true")
27165
rec := httptest.NewRecorder()
166+
// Assert we're not using IP address.
167+
req.RemoteAddr = randRemoteAddr()
28168
rtr.ServeHTTP(rec, req)
29169
resp := rec.Result()
30170
defer resp.Body.Close()

codersdk/client.go

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ const (
2424
SessionCustomHeader = "Coder-Session-Token"
2525
OAuth2StateKey = "oauth_state"
2626
OAuth2RedirectKey = "oauth_redirect"
27+
28+
// nolint: gosec
29+
BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit"
2730
)
2831

2932
// New creates a Coder client for the provided URL.

0 commit comments

Comments
 (0)