Skip to content

fix: rate limit the OAuth2 endpoints - #29510

Draft
BobbyHo wants to merge 8 commits into
mainfrom
fix/oauth2-rate-limit
Draft

BobbyHo wants to merge 8 commits into
mainfrom
fix/oauth2-rate-limit

Conversation

@BobbyHo

@BobbyHo BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

The endpoints under /oauth2 had no rate limit. The whole tree is mounted outside the API rate limiter groups, so an unauthenticated caller could send unlimited requests. RFC 6749 section 2.3.1 and OAuth 2.1 section 2.4.1 require the server to protect any endpoint that takes a client secret against brute force. This closes finding D6-01 in the spec compliance matrix.

The login rate limit now applies to /authorize, /tokens, /revoke, /register, the RFC 7592 client configuration routes, and the two /.well-known discovery endpoints. Requests with no Coder session are counted per IP address and the rest are counted per user. An Owner can bypass the limit on a single request with X-Coder-Bypass-Ratelimit, and the limit is off when --dangerous-disable-rate-limits is set, same as login.

The limiter is mounted on each route rather than on the /oauth2 tree. A tree-wide mount also counts requests to paths that match no route, which would let junk traffic from one address spend the budget that real OAuth2 traffic needs, and it would put every endpoint on a single budget. Each route now carries its own counter, so an endpoint drained by one caller leaves the others alone. The cost is that a route added under /oauth2 later has to mount the limiter itself.

The bucket keys on a fixed name rather than the request path, because two of these routes vary their path. /oauth2/clients/{client_id} carries the client ID, and /.well-known/oauth-protected-resource answers on any suffix per RFC 9728. Keying by path would let a caller vary either one and get a fresh bucket on every request. The client configuration route also takes a bearer token from a caller with no API key and queries the database before it checks the token.

Guessing a secret was already infeasible, since secrets are 40 random characters compared in constant time. The gap was the missing control on the endpoints and the unbounded database work that came with it.

🤖 Generated with Claude Code

- POST /oauth2/tokens, /oauth2/revoke, and /oauth2/register had no rate limit
- They now share the login limit of 60 requests per minute per IP address
- The limiter runs before the app lookup so refused requests never reach the database
- DELETE /oauth2/tokens is authenticated and keeps its own per-user bucket
- Add a test for each endpoint and a note in the provider docs
@github-actions

github-actions Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Chat: Review in progress (2/2 reviewers complete) | View chat
Requested: 2026-09-18 18:42 UTC by @BobbyHo

deep-review v0.9.0 | Round 2 | eec4915..7804b7e

Last posted: Round 2, 12 findings (2 P2, 3 P3, 3 Nit, 4 Note), COMMENT. Review

Finding inventory

Finding inventory

PR #29510. Coder review findings (CRF).

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P2 Author fixed (8ccddaa) coderd/coderd.go:1277 RFC 7592 /oauth2/clients/{client_id} bearer-credential routes left unlimited R1 Netero Yes
CRF-2 P3 Author fixed (8ccddaa) coderd/oauth2provider/ratelimit_test.go:97 Comment claims DELETE /oauth2/tokens has a per-user bucket; it has no limiter R1 Netero Yes
CRF-3 P3 Author fixed (394e87f) coderd/oauth2provider/ratelimit_test.go:19 Per-endpoint bucket separation is asserted in docs but untested; test comment's stated reason is wrong R1 Netero Yes
CRF-4 P3 Author fixed (7804b7e) coderd/coderd.go:1290 429 body is codersdk.Response, not an RFC 6749 section 5.2 error object R1 Netero Yes
CRF-5 Note Author fixed (8ccddaa) coderd/oauth2provider/ratelimit_test.go:100 DeleteTokensNotLimited passes against the base commit R1 Netero Yes
CRF-6 Note Author fixed (7804b7e) docs/admin/integrations/oauth2-provider.md:695 Docs omit --dangerous-disable-rate-limits and that 60 is not configurable R1 Netero Yes
CRF-7 P4 Fixed on main (#29568, merged in d771862) coderd/database/migrations/000596_aibridge_workspace_context.up.sql Duplicate migration version 000596 on main blocks all Postgres-backed tests R1 Netero Yes (body; reviewer reported no line)
CRF-8 P2 Open coderd/coderd.go:1265 Tree-level mount counts unmatched /oauth2/* paths against the shared bucket, so junk traffic denies OAuth2 to every caller on that IP R2 Netero Yes
CRF-9 P2 Open coderd/coderd.go:1242 RFC 8414 discovery endpoint still unauthenticated, unlimited, one DB query per request R2 Netero Yes (body; outside diff)
CRF-10 P3 Open coderd/httpmw/ratelimit.go:53 429 body drops message, so the dashboard shows "Failed to revoke application." and the SDK shows "unexpected status code 429, response has no message" R2 Orchestrator Yes (reply on CRF-4 thread)
CRF-11 Nit Open coderd/httpmw/ratelimit.go:55 rateLimitMessage comment claims every limiter shares the message; RateLimitByAuthToken does not R2 Netero Yes
CRF-12 Nit Open coderd/httpmw/ratelimit.go:36 RateLimitOAuth2 doc comment says one bucket per route, the opposite of the code R2 Netero Yes
CRF-13 Nit Open docs/admin/integrations/oauth2-provider.md:698 Docs say only --dangerous-disable-rate-limits turns the limit off; an Owner can bypass per request R2 Netero Yes
CRF-14 Note Open coderd/coderd.go:1265 A throttled browser in the consent flow receives a JSON OAuth2 error body R2 Netero Yes
CRF-15 Note Open coderd/oauth2provider/ratelimit_test.go:18 /authorize is the one route in the tree with no direct test case R2 Netero Yes
CRF-16 Note Open coderd/httpmw/ratelimit_test.go RateLimitOAuth2 has no unit test beside its two siblings R2 Netero Yes (body; reviewer reported no line)
CRF-17 Note Dropped by orchestrator (asserts no defect; httprate already sets the header and no code suggests otherwise) coderd/httpmw/ratelimit.go:48 Retry-After is already set by httprate R2 Netero No

Contested and acknowledged

None.

Law analysis

Not run. Effective additions 125 (R1) and 212 (R2), below the 1000 threshold.

Round log

Round 1

Netero-only first pass (P2 present, panel gated). 1 P2, 3 P3, 2 Note, 1 P4.
Reviewed against c47eb47..836f48c.
Orchestrator verified all findings against the head worktree: router wiring at
coderd.go:1255-1316, RequireRegistrationAccessToken at
oauth2provider/registration.go:494-570, and the limit handler and key funcs at
httpmw/ratelimit.go:43-125.

Round 2

Churn guard: PROCEED. All 7 prior findings classified addressed (author claims,
unverified by the panel). CRF-7 resolved on main by #29568, pulled in via merge
d771862. Reviewed against eec4915..7804b7e.
Netero re-verified all six author-claimed fixes against head and confirmed each,
including a reverse-patch run where all six subtests fail without the production
change. Second Netero-only round (2 new P2s, panel still gated); the consecutive
Netero-only cap is now reached, so round 3 goes to the panel regardless.
New: 2 P2, 1 P3 (orchestrator), 3 Nit, 3 Note, 1 dropped.
Orchestrator verification this round: the /oauth2 tree mount at
coderd.go:1255-1266, RateLimitOAuth2 and rateLimitWithEndpointKey at
httpmw/ratelimit.go:34-80, the discovery route at coderd.go:1241-1245 and its
per-request GetOAuth2DCREnabled at oauth2provider/metadata.go:19-24, and the
429 consumer chain (codersdk.ReadBodyAsError at codersdk/client.go:445-461,
getErrorMessage/getErrorDetail at site/src/api/errors.ts:74-120, and the
revoke toast at OAuth2ProviderPage.tsx:55-61).

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review only. These are mechanical findings from a single reviewer; the full review panel has not looked at this PR yet and will review once these are addressed.

The wiring is right where it is applied: the limiter sits before the app lookup on both /tokens POST and /revoke, so a refused request never reaches the database, and DELETE /tokens keeps its own middleware group. Reusing LoginRateLimit instead of adding a flag is the correct call, and the test helpers (doRequest, formContentType, sessionToken) are reused rather than redefined. Three of the four new subtests fail against the base commit, so they test the change.

1 P2, 3 P3, 2 Notes, 1 P4.

The P2 is the one to look at first: the same defect this PR closes is still open one route over. From the reviewer: "This is the same defect the PR set out to close, one route over."

P4 [CRF-7] coderd/database/migrations/000596_aibridge_workspace_context.up.sql: two migrations share version 000596 on origin/main (000596_aibridge_workspace_context.* from #29235 and 000596_template_usage_stats_session_usage.* from #29109). Every Postgres-backed test fails with failed to init driver with path .: duplicate migration file before it starts; renumbering the aibridge pair in a scratch worktree was required to run the new test at all. Present at the base commit, so not this PR's defect and not this PR's to fix, but it needs a ticket or a fix on main.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/coderd.go Outdated
Comment thread coderd/coderd.go Outdated
Comment thread coderd/oauth2provider/ratelimit_test.go
Comment thread coderd/oauth2provider/ratelimit_test.go Outdated
Comment thread coderd/oauth2provider/ratelimit_test.go Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
- The per-endpoint limiter missed GET, PUT, and DELETE
  /oauth2/clients/{client_id}. They take a bearer token from a caller
  with no API key, and the middleware hits the database before it
  checks that token.
- Mount the limiter on the tree so a new route is covered by default.
- Key the bucket on a fixed name, not the path. The client ID is in
  the path, so a caller could vary it to get a fresh bucket.
- /authorize and DELETE /tokens are now counted too. They key on the
  user.
@BobbyHo BobbyHo changed the title fix: rate limit the OAuth2 token, revoke, and register endpoints fix: rate limit the OAuth2 endpoints Sep 18, 2026
- The docs say all /oauth2 endpoints draw on one limit, but no test
  checked that across two endpoints.
- Spend the budget on /register, then assert /revoke returns 429
  instead of its usual 401.
- A refused request under /oauth2 returned {"message": ...}, which a
  client decoding OAuth2 errors cannot read. It now returns
  temporarily_unavailable, the closest code RFC 6749 offers for
  throttling.
- Name the flag that disables the limit in the docs, and say the 60 is
  fixed rather than configurable.
@BobbyHo

BobbyHo commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second first-pass round. These are mechanical findings from a single reviewer; the full panel still has not reviewed this PR. It reviews next round regardless of what lands, since two first-pass rounds is the limit.

Every round 1 finding is fixed, and the fixes were verified rather than taken on trust: reverse-patching the two production files makes all six subtests fail, so the new tests hold the behavior down. Two of the fixes are better than what was asked for. RateLimitOAuth2 keying on a fixed name instead of the path is the detail that actually closes the RFC 7592 hole, since a caller cycling client IDs would have got a fresh bucket per request, and ClientConfigurationVaryingClientID pins it. Extracting rateLimitMessage and threading onLimited keeps the other two limiters byte-identical while the OAuth2 tree gets an RFC 6749 body.

New this round: 2 P2, 1 P3, 3 Nits, 3 Notes.

The headline P2 is a consequence of the broad fix. Mounting on the tree means chi runs the limiter before it decides whether a route matches, so /oauth2/<anything> spends the same bucket as the real endpoints. From the reviewer: "Before this PR that traffic could not deny service to anyone; the limiter is what makes it possible."

Two items need a decision from you rather than a code change from the author. CRF-9 (the discovery endpoint) is outside this diff but is the same defect class the PR closes, with the endpoint's own comment naming rate limiting as its mitigation: fix it here, file an issue, or say why it stays open. CRF-10 is a consequence the author named and accepted in a reply; an agent cannot accept a user-visible regression as permanent, so it needs a human call.

P2 [CRF-9] coderd/coderd.go:1242: the RFC 8414 discovery endpoint is still unauthenticated, unlimited, and runs a database query per request. (Netero)

/.well-known/oauth-authorization-server is mounted on the root router with only RequireOAuth2Provider, and the root r.Use chain contains no rate limiter; apiRateLimiter is applied per group, not at the root. Its handler queries the database on every request, and its own comment names rate limiting as the mitigation: "a flood of requests should be mitigated with rate limiting or firewalling, not a cache" (coderd/oauth2provider/metadata.go:19-24).

I confirmed the wiring at coderd/coderd.go:1241-1245 and the unconditional GetOAuth2DCREnabled call. The route also registers Get("/*"), so every path under the prefix reaches that query. /.well-known/oauth-protected-resource needs nothing; it touches no database. The PR's stated rationale is the unbounded database work that came with the missing control, and that work is still reachable here with no credential and no limit.

Note [CRF-16] coderd/httpmw/ratelimit_test.go: RateLimitOAuth2 has no unit test beside its two siblings, which both have one. (Netero)

TestRateLimit and TestRateLimitByAPICompatibilityEndpoint live in coderd/httpmw/ratelimit_test.go; the new exported function is exercised only through the oauth2provider integration test. That test covers the 429 status, the RFC 6749 body, the shared bucket, and per-user keying, which is the whole observable contract, so a unit test would add location rather than coverage.

Recorded, not requested. Worth knowing that deleting or moving the oauth2provider test leaves the exported function uncovered.

Process note: make lint/go cannot run in a review worktree here. /tmp/coder-script-data/bin/golangci-lint is a shim that refuses to run and shadows the mise-installed binary on PATH, so the Makefile target invokes the shim. Running the pinned 2.0.2 binary directly reports 0 issues on both changed packages. Not a PR defect; noted so the clean-lint claim is checkable.

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/coderd.go Outdated
Comment thread coderd/coderd.go Outdated
Comment thread coderd/httpmw/ratelimit.go Outdated
Comment thread coderd/httpmw/ratelimit.go Outdated
Comment thread docs/admin/integrations/oauth2-provider.md Outdated
Comment thread coderd/oauth2provider/ratelimit_test.go
The limiter was mounted on the /oauth2 subrouter, so requests to paths
that match no route spent the budget too. Junk traffic from one address
could refuse OAuth2 service to every caller behind that address, and all
endpoints drew on the same budget.

Mount the limiter on each route instead. Every endpoint now counts on its
own, and a path that reaches no handler counts for nothing. Extend it to
the two .well-known discovery endpoints, which carry no credential and so
are limited per address.
@BobbyHo

BobbyHo commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review re-review the P2 and P3 reported in the last review. Only report if there are more P0 to P3.

@BobbyHo

BobbyHo commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

/coder-agents-review re-review the P2 and P3 reported in the last review. Only report if there are more P0 to P3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant