Skip to content

fix(coderd): harden OAuth2 provider security#22194

Merged
ThomasK33 merged 20 commits intomainfrom
csrf-sjx1
Feb 23, 2026
Merged

fix(coderd): harden OAuth2 provider security#22194
ThomasK33 merged 20 commits intomainfrom
csrf-sjx1

Conversation

@ThomasK33
Copy link
Member

@ThomasK33 ThomasK33 commented Feb 19, 2026

Summary

Harden the OAuth2 provider with multiple security fixes addressing coder/security#121 (CSRF session takeover) and converge on OAuth 2.1 compliance.

Security Fixes

Fix Description Commits
CSRF on /oauth2/authorize Enforce CSRF protection on the authorize endpoint POST (consent form submission) ba7d646, b94a64e
Clickjacking: frame-ancestors CSP Prevent consent page from being iframed (Content-Security-Policy: frame-ancestors 'none' + X-Frame-Options: DENY) 597aeb2
Exact redirect URI matching Changed from prefix matching to full string exact matching per OAuth 2.1 §4.1.2.1 73d64b1, 93897f1
Store & verify redirect_uri Store redirect_uri with auth code in DB, verify at token exchange matches exactly (RFC 6749 §4.1.3) 50569b9, d7ca315
Mandatory PKCE Require code_challenge at authorization (for response_type=code) + unconditional code_verifier verification at token exchange d7ca315, 1cda1a9
Reject implicit grant response_type=token now returns unsupported_response_type error page (OAuth 2.1 removes implicit flow) d7ca315, 91b8863

Changes by File

coderd/httpmw/csrf.go — Extended the CSRF ExemptFunc to enforce CSRF on /oauth2/authorize in addition to /api routes. The consent form POST is now CSRF-protected to prevent cross-site authorization code theft.

site/site.go — Added Content-Security-Policy: frame-ancestors 'none' and X-Frame-Options: DENY headers to RenderOAuthAllowPage (consent page only — does not affect the SPA/global CSP used by AI tasks).

coderd/httpapi/queryparams.go — Changed RedirectURL from prefix matching (strings.HasPrefix(v.Path, base.Path)) to full URI exact matching (v.String() != base.String()), comparing scheme, host, path, and query.

coderd/oauth2provider/authorize.go — Added PKCE enforcement: code_challenge is required when response_type=code (via a conditional check, not RequiredNotEmpty, so response_type=token can reach the explicit rejection path). ShowAuthorizePage (GET) validates response_type before rendering and returns a 400 error page for unsupported types. ProcessAuthorize (POST) stores the redirect_uri with the auth code when explicitly provided.

coderd/oauth2provider/tokens.go — PKCE verification is now unconditional (not gated on code_challenge being present in DB). If the stored code has a redirect_uri, the token endpoint verifies it matches exactly — mismatch returns errBadCodeinvalid_grant. Missing code_verifier returns invalid_grant.

codersdk/oauth2.goOAuth2ProviderResponseTypeToken constant and Valid() acceptance are kept so the authorize handler can parse response_type=token and return the proper unsupported_response_type error rather than failing at parameter validation.

coderd/database/migrations/000421_* — Added redirect_uri text column to oauth2_provider_app_codes.

Design Decisions

state parameter remains optional — The plan initially required state via RequiredNotEmpty, but this was reverted in 376a753 to avoid breaking existing clients. The state is still hashed and stored when provided (via state_hash column), securing clients that opt in.

response_type=token kept in Valid() — Removing it from Valid() would cause the parameter parser to reject the request before the authorize handler can return the proper unsupported_response_type error. The constant is kept for correct error handling flow.

CSP scoped to consent page onlyframe-ancestors 'none' is set only on the OAuth consent page renderer, not globally. The SPA/global CSP was previously changed to allow framing for AI tasks (#18102); this change does not regress that.

Out of Scope (follow-up PRs)

  • Bearer tokens in query strings (needs internal caller audit)
  • Scope enforcement on OAuth2 tokens
  • Rate limiting on dynamic client registration

📋 Implementation Plan

Plan: Harden OAuth2 Provider — Security Fixes + OAuth 2.1 Compliance

Context & Why

Security issue coder/security#121 reports a critical session takeover via CSRF on the OAuth2 provider. This plan covers all remaining security fixes from that issue plus convergence on OAuth 2.1 requirements. The goal is a single PR that closes all actionable gaps.

Current State (already committed on branch csrf-sjx1)

Fix Status Commits
Fix 1: CSRF on /oauth2/authorize ✅ Done ba7d646, b94a64e
CSRF token in consent form HTML ✅ Done b94a64e
state_hash column + storage ✅ Done (hash stored, but state still optional) 9167d83, b94a64e
Tests for CSRF + state hash ✅ Done e4119b5

Remaining Work

Fix 2 — Require state parameter (DROPPED)

Decision: Do not enforce state as required. The state parameter is still hashed and stored when provided (via hashOAuth2State / state_hash column from prior commits), but clients are not forced to supply it. This avoids breaking existing integrations that omit state.

Rollback: Remove "state" from the RequiredNotEmpty call in coderd/oauth2provider/authorize.go:42:

// BEFORE (current on branch)
p.RequiredNotEmpty("response_type", "client_id", "state", "code_challenge")

// AFTER
p.RequiredNotEmpty("response_type", "client_id", "code_challenge")

No test changes needed — tests already pass state voluntarily.

Fix 4 — Exact redirect URI matching

Currently coderd/httpapi/queryparams.go:233 uses prefix matching:

// CURRENT — prefix match
if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) {

OAuth 2.1 requires exact string matching. Change to:

// AFTER — exact match (OAuth 2.1 §4.1.2.1)
if v.Host != base.Host || v.Path != base.Path {

File: coderd/httpapi/queryparams.goRedirectURL method

Also update the error message from "must be a subset of" to "must exactly match".

Additionally, store redirect_uri with the auth code and verify at the token endpoint (RFC 6749 §4.1.3):

  1. New migration (same migration file or a new 000421): Add redirect_uri text column to oauth2_provider_app_codes
  2. Update INSERT query in coderd/database/queries/oauth2.sql to include redirect_uri
  3. coderd/oauth2provider/authorize.go: Store params.redirectURL.String() when inserting the code
  4. coderd/oauth2provider/tokens.go: After retrieving the code from DB, verify that redirect_uri from the token request matches the stored value exactly. Currently tokens.go:103 calls p.RedirectURL(vals, callbackURL, "redirect_uri") for prefix validation only — it must compare against the stored redirect_uri from the code, not just the app's callback URL.
Why both exact match AND store+verify?

Exact matching at the authorize endpoint prevents open redirectors (attacker can't use a sub-path).
Storing and verifying at the token endpoint prevents code injection — an attacker who steals a code can't exchange it with a different redirect_uri than was originally authorized. This is required by RFC 6749 §4.1.3 and OAuth 2.1.

Fix 7 — frame-ancestors CSP on consent page

The consent page can be iframed by a workspace app (same-site), which is the attack vector. Add a Content-Security-Policy header to prevent framing.

File: site/site.goRenderOAuthAllowPage function (~line 731)

Before writing the response, add:

func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) {
    rw.Header().Set("Content-Type", "text/html; charset=utf-8")
    // Prevent the consent page from being framed to mitigate
    // clickjacking attacks (coder/security#121).
    rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
    rw.Header().Set("X-Frame-Options", "DENY")
    ...

Both headers for defense-in-depth (CSP for modern browsers, X-Frame-Options for legacy).

OAuth 2.1 — Mandatory PKCE

Currently PKCE is checked only when code_challenge was provided during authorization (tokens.go:258):

// CURRENT — conditional check
if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" {
    // verify PKCE
}

OAuth 2.1 requires PKCE for ALL authorization code flows. Change to:

File: coderd/oauth2provider/authorize.go — Add "code_challenge" to required params:

p.RequiredNotEmpty("response_type", "client_id", "code_challenge")

File: coderd/oauth2provider/tokens.go:257-265 — Make PKCE verification unconditional:

// AFTER — PKCE always required (OAuth 2.1)
if req.CodeVerifier == "" {
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !dbCode.CodeChallenge.Valid || dbCode.CodeChallenge.String == "" {
    // Code was issued without a challenge — should not happen
    // with the authorize endpoint enforcement, but defend in
    // depth.
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}
if !VerifyPKCE(dbCode.CodeChallenge.String, req.CodeVerifier) {
    return codersdk.OAuth2TokenResponse{}, errInvalidPKCE
}

File: codersdk/oauth2.go — Remove OAuth2ProviderResponseTypeToken from the enum or reject it explicitly in the authorize handler. Currently it's defined at line 216 but the handler ignores response_type and always issues a code. We should either:

  • (a) Remove the "token" variant from the enum and reject it with unsupported_response_type, OR
  • (b) Add an explicit check in ProcessAuthorize that rejects response_type=token

Option (b) is simpler and more backwards-compatible:

// In ProcessAuthorize, after extracting params:
if params.responseType != codersdk.OAuth2ProviderResponseTypeCode {
    httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest,
        codersdk.OAuth2ErrorCodeUnsupportedResponseType,
        "Only response_type=code is supported")
    return
}

OAuth 2.1 — Bearer tokens in query strings

coderd/httpmw/apikey.go:743 accepts access_token from URL query parameters. OAuth 2.1 prohibits this. However, this may be used internally (e.g., workspace apps, DERP). Need to audit callers before removing.

Approach: This is a larger change with potential breakage. Mark as a separate follow-up issue rather than including in this PR. Document the finding.

OAuth 2.1 — Removed flows

Already compliant. tokens.go only supports authorization_code and refresh_token grant types. The implicit grant (response_type=token) will be explicitly rejected per the PKCE section above.

OAuth 2.1 — Refresh token rotation

Already compliant. tokens.go:442 deletes the old API key when a refresh token is used.

Migration Plan

All DB changes can go in a single new migration (or extend 000420 if the branch is rebased before merge). Columns to add:

  • redirect_uri text on oauth2_provider_app_codes

The state_hash column is already added by migration 000420.

Implementation Order

  1. Fix 7 — CSP headers on consent page (isolated, no deps)
  2. Fix 2 — Require state parameter (DROPPED — state stays optional)
  3. Fix 4 — Exact redirect URI matching + store/verify redirect_uri
  4. PKCE mandatory — Require code_challenge + reject response_type=token
  5. Rollback — Remove "state" from RequiredNotEmpty in authorize.go
  6. Tests — Update/add tests for all changes
  7. make gen after DB changes

Out of Scope (separate PRs)

  • Bearer tokens in query strings (needs internal caller audit)
  • Scope enforcement on OAuth2 tokens
  • Rate limiting / quota on dynamic client registration

Generated with mux • Model: anthropic:claude-opus-4-6 • Thinking: xhigh

@ThomasK33 ThomasK33 changed the title fix(oauth2): add CSRF protection to /oauth2/authorize endpoint fix(oauth2): harden OAuth2 provider — CSRF, PKCE, state, redirect_uri, CSP Feb 19, 2026
@coder-tasks
Copy link
Contributor

coder-tasks bot commented Feb 19, 2026

Documentation Check

Updates Needed

  • docs/admin/integrations/oauth2-provider.md — The PKCE Flow section currently describes PKCE as "recommended" and optional. The PR description stated PKCE would be mandatory, but the final implementation (authorize.go) does not add code_challenge to RequiredNotEmpty — PKCE remains optional. The section heading remains accurate, but a note clarifying that PKCE is strongly recommended (and required for OAuth 2.1 compliance) would improve the docs.

    Addressed in 3e8048c — section renamed to "PKCE Flow (Required)", text updated to state PKCE is mandatory per OAuth 2.1, and a note added above the standard flow example pointing to the PKCE section.

  • [ ] docs/admin/integrations/oauth2-provider.md — The Standard OAuth2 Flow section shows state=random-string but does not note that state is now a required parameter (OAuth 2.1 mandate). A note should clarify this is required, not optional. (reverted — state was made optional again for compatibility in 376a753)

  • docs/admin/integrations/oauth2-provider.md — The PR rejects response_type=token (implicit grant) in ShowAuthorizePage with an "Unsupported Response Type" error page. There is no mention of this in the docs. A note under Limitations or Standards Compliance should state that the implicit grant / response_type=token is not supported (removed in OAuth 2.1).

    Addressed in 3e8048c — Limitations section now documents that implicit grant is not supported and returns unsupported_response_type.

  • docs/admin/integrations/oauth2-provider.md — The Standards Compliance section references RFC 6749 but not OAuth 2.1. Since this PR explicitly targets OAuth 2.1 compliance (exact redirect URI matching, rejected implicit grant, CSRF protection), the section should reference the OAuth 2.1 draft and explain the stricter requirements for integrators.

    Addressed in 3e8048c — Standards Compliance section now references the OAuth 2.1 draft and enumerates the enforced requirements.

  • docs/admin/integrations/oauth2-provider.md — The Standard OAuth2 Flow example shows a flow without PKCE. Since PKCE is strongly recommended and the server now rejects response_type=token, it would reduce confusion to note that the standard flow example works but PKCE (via the PKCE Flow section) is the recommended path.

    Addressed in 3e8048c — A NOTE callout was added above the PKCE section clarifying the standard flow example omits the mandatory code_challenge and directing readers to the PKCE section.

  • [ ] docs/admin/integrations/oauth2-provider.md — The PR changes redirect URI validation from prefix matching to exact matching (OAuth 2.1 §4.1.2.1). Any existing documentation or examples that imply sub-path redirect URIs are acceptable should be updated to reflect exact matching is now required. (The troubleshooting section already says "exactly matches"; no doc change appears needed for this item)


Automated review via Coder Tasks

@ThomasK33 ThomasK33 changed the title fix(oauth2): harden OAuth2 provider — CSRF, PKCE, state, redirect_uri, CSP fix(oauth2): improve OAuth2 provider's security with CSRF, PKCE, state, redirect_uri, and CSP Feb 19, 2026
@ThomasK33
Copy link
Member Author

@codex review

@ThomasK33 ThomasK33 changed the title fix(oauth2): improve OAuth2 provider's security with CSRF, PKCE, state, redirect_uri, and CSP fix(coderd): harden OAuth2 provider security Feb 19, 2026
Copy link
Contributor

@coder-tasks coder-tasks bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is a solid set of security hardening changes for the OAuth2 provider: CSRF enforcement on the consent endpoint, mandatory PKCE, exact redirect URI matching, clickjacking mitigations, and redirect_uri binding to auth codes. The overall approach is correct.

I found one important bug and a misleading comment worth fixing before merge.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 376a753e52

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Contributor

@coder-tasks coder-tasks bot left a comment

Choose a reason for hiding this comment

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

Code Review

This PR implements important OAuth2 security hardening: CSRF protection on the consent page, mandatory PKCE (OAuth 2.1), exact redirect URI matching, clickjacking prevention, and redirect URI binding to authorization codes. The changes are well-structured and the security intent is sound.

Found 2 issues: one important logic bug where a check is unreachable, and one minor RFC compliance deviation.

@ThomasK33
Copy link
Member Author

@codex review

Copy link
Contributor

@coder-tasks coder-tasks bot left a comment

Choose a reason for hiding this comment

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

Code Review

Reviewed OAuth2 provider hardening: CSRF protection on consent endpoint, mandatory PKCE, exact redirect_uri matching, store/verify redirect_uri at token exchange, frame-ancestors CSP, and response_type=token rejection. The overall approach is solid and the security fixes are correct. Found two issues worth addressing.

Security Analysis

  • CSRF protection: Correctly implemented — the nosurf ExemptFunc now keeps /oauth2/authorize in scope, and the HTML consent form includes <input name="csrf_token"> matching nosurf's FormFieldName constant. Bearer-token requests correctly bypass CSRF via the existing exempt path. ✅
  • Mandatory PKCE: code_challenge is now in RequiredNotEmpty and VerifyPKCE uses subtle.ConstantTimeCompare. Unconditional verification at token exchange is correct. ✅
  • redirect_uri store & verify: Stored as params.redirectURL.String() (normalized), verified as exact string match in authorizationCodeGrant. RFC 6749 §4.1.3 compliance is correct — when redirect_uri was absent during authorization, RedirectUri.Valid=false so no check is performed at token exchange. ✅
  • frame-ancestors CSP: Added to RenderOAuthAllowPage before the template executes. Both CSP and X-Frame-Options for defense-in-depth is correct. ✅
  • Implicit grant rejection: Correctly returns unsupported_response_type in ProcessAuthorize. ✅
  • state_hash: Stored but intentionally not verified server-side (state verification is the OAuth2 client's responsibility). The migration is correct. ✅

@ThomasK33
Copy link
Member Author

@codex review

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Bravo.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
Copy link
Member Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 64731246a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
Copy link
Member Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93897f17f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ThomasK33
Copy link
Member Author

@Emyrk correction on my prior note: this is scoped to the OAuth consent page renderer (RenderOAuthAllowPage) only, and it does not change the SPA/global CSP used by AI tasks. So framing restrictions are limited to the consent UI as a clickjacking defense.

@ThomasK33
Copy link
Member Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1cda1a91aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Contributor

@jdomeracki-coder jdomeracki-coder left a comment

Choose a reason for hiding this comment

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

@ThomasK33 we should adjust the PR description to remove the incorrect statement around state enforcement
As discussed state parameter isn't required in accordance with the corresponding RFC

Screenshot 2026-02-23 at 10 02 51

@ThomasK33
Copy link
Member Author

@ThomasK33 we should adjust the PR description to remove the incorrect statement around state enforcement
As discussed state parameter isn't required in accordance with the corresponding RFC

Screenshot 2026-02-23 at 10 02 51

Thanks, updated it.

…ct URI matching

Update all OAuth2 tests to comply with the new OAuth 2.1 security
requirements where state, code_challenge, and code_verifier are now
mandatory parameters.

Changes:
- coderd/oauth2_test.go: Add generatePKCE helper; update authorizationFlow
  to return verifier; update all 4 callers and 3 manual flows; update
  customTokenExchange to accept codeVerifier; update NestedPath test to
  expect authError (exact redirect URI matching)
- coderd/oauth2provider/oauth2providertest/oauth2_test.go: Rename
  TestOAuth2WithoutPKCE to TestOAuth2WithoutPKCEIsRejected (expects 400);
  add PKCE to ClientSecretBasic and ClientSecretBasicInvalidSecret tests
- coderd/mcp/mcp_e2e_test.go: Add mcpGeneratePKCE helper; add PKCE to
  static and dynamic client auth flows and token exchanges
- coderd/database/dbgen/dbgen.go: Add missing RedirectUri field to
  OAuth2ProviderAppCode seed
@ThomasK33 ThomasK33 merged commit b776a14 into main Feb 23, 2026
32 checks passed
@ThomasK33 ThomasK33 deleted the csrf-sjx1 branch February 23, 2026 11:18
@github-actions github-actions bot locked and limited conversation to collaborators Feb 23, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants