Files
pad/internal/server/middleware_csrf.go
T
xarmian d36f27c29f fix(server): auth-perimeter hardening — B6–B9 from the IDEA-1927 audit (TASK-1932) (#811)
* fix(server): stop autoCreateWorkspace from swallowing member-add errors (B6, TASK-1932)

A failed AddWorkspaceMember after workspace creation used to be silently
discarded, leaving a workspace that's completely unreachable (owner_id
alone grants no access) and invisible in the console forever. Retry once,
then clean up the orphaned workspace and log loudly on continued failure
so on-call can act on it.

* fix(server): fail fast when cloud mode runs without secure cookies (B7, TASK-1932)

SetCloudMode never forced secureCookies on, so PAD_CLOUD=true without
PAD_SECURE_COOKIES was an unenforced ops contract: OAuth's __Host-prefixed
session cookie is silently invisible to pad's own cookie reader without
Secure set, producing a "logged in but appears logged out" failure mode.
Add Config.ValidateCloudSecureCookies and check it at server startup,
next to the existing PAD_CLOUD_SECRET requirement, so the misconfiguration
is a startup error instead of a runtime mystery.

* fix(server): align OAuth session TTL with web session TTL (B9, TASK-1932)

handleOAuthLogin minted a 30-day session while every other web login used
the 7-day webSessionTTL. createAuthSession derives the store session row,
session cookie MaxAge, and CSRF cookie MaxAge all from one ttl argument,
so the longer OAuth cookie outlived its own server-side session — the
browser kept presenting a cookie whose session had already expired,
producing silent 401s. Use webSessionTTL for OAuth logins too.

* fix(server): narrow the /api/v1/auth/* CSRF exemption to anonymous endpoints (B8, TASK-1932)

The CSRF middleware exempted the entire /api/v1/auth/ prefix, which also
covered mutating cookie-authenticated endpoints: PATCH /me, oauth-unlink,
2FA setup/verify/disable, delete-account, token create/delete/rotate, CLI-
session approve, and logout. Replace the prefix bypass with an exact-path
allowlist of the endpoints that are genuinely pre-session (login, register,
bootstrap, password reset, verify-email, resend-verification, 2FA login
challenge, CLI session create) or authenticate purely via a cloud secret
rather than a cookie (oauth-login, oauth-link — never touch the session,
so CSRF isn't a meaningful threat model for them and the sidecar has no
CSRF cookie to send). Everything else now requires the double-submit token
like any other authenticated mutation; the web client already sends it on
every non-GET/HEAD request, so no frontend change is needed.

* docs(server): pin the deliberate CSRF-cookie legacy-fallback asymmetry (TASK-1932)

Codex review (round 1) flagged that SessionAuth falls back from the
__Host-pad_session cookie to the legacy unprefixed name, but the CSRF
cookie lookup has no equivalent fallback — meaning a browser holding
pre-secure-cookies-flip legacy cookies stays authenticated but gets 403'd
on B8's newly CSRF-required endpoints until it re-logs-in.

This asymmetry is deliberate, not a bug: the session cookie's value is an
unguessable secret regardless of which name carries it, but the CSRF
cookie's security property depends on the attacker being unable to set
the cookie itself — an unprefixed name is settable from a sibling
subdomain, which is exactly the hole __Host- exists to close. Restoring
"symmetry" here would silently reopen it. Document the reasoning at the
cookie lookup so a future maintainer doesn't "fix" it, and add a pinning
test that exercises the exact scenario (secureCookies=true, legacy
session + CSRF cookies, CSRF-required endpoint) end to end.

* fix(server): require CSRF for session-authenticated requests to exempt auth paths (TASK-1932)

Codex round 2 found a P1: handleRegister has an admin-session branch (an
already-logged-in admin can create a verified account with no invitation
code), but /api/v1/auth/register was unconditionally CSRF-exempt by path.
A cross-site POST could ride the admin's cookie into that branch with no
CSRF token — the same class of hole as the oauth-unlink case B8 already
closed, just missed because register's other paths are genuinely
anonymous.

Fix generically rather than register-specifically: gate the
authCSRFExemptPaths exemption on currentUser(r) == nil. SessionAuth runs
before CSRFProtect, so a request that resolved to a real session falls
through to the normal double-submit check instead of the early exemption,
while a genuinely anonymous request keeps it. This also covers any future
session-authenticated branch a handler on this list grows, with no
handler changes. Bearer/PAT and cloud-secret (oauth-login/oauth-link)
callers are unaffected — they have their own unconditional exemptions
later in the same function.

* fix(server): require validated Bearer/cloud-secret auth for CSRF exemption (TASK-1932)

Codex round 3 found that CSRFProtect's Bearer and X-Cloud-Secret exemptions
fired on header/marker PRESENCE, not validation. TokenAuth deliberately
falls through (rejectInvalidBearer) instead of 401ing invalid Bearers on
/api/v1/auth/* paths to support CLI-token recovery, so a cross-site request
carrying a victim's real session cookie plus a garbage Bearer header could
ride the cookie past CSRF on any newly-CSRF-required endpoint. The same
presence-only pattern in the X-Cloud-Secret exemption is concretely
exploitable too: handleSetPlan (and similarly-shaped handlers) accept an
admin cookie session as an alternative to the secret, so a garbage
X-Cloud-Secret plus a stolen admin cookie could set an arbitrary user's
plan with no CSRF token at all.

Add ctxValidatedSessionBearer (set by TokenAuth only on successful
ValidateSession for CLI session-bearer tokens) alongside the existing
ctxIsAPIToken, and a combined isValidatedBearerAuth() helper. CSRFProtect
now exempts unconditionally only on validated Bearer auth; an unvalidated
Bearer header or cloud-secret marker is exempt only when no session was
also resolved for the request (currentUser(r) == nil), preserving the
CLI-recovery contract (stale token, no cookie -> 401 from auth, not
csrf_error) while closing the cookie-riding case.

* fix(server): split CSRF auth-exempt allowlist by session sensitivity (TASK-1932)

Codex round 2 gated the entire authCSRFExemptPaths allowlist on
currentUser(r) == nil to close handleRegister's admin-session branch, but
that gate applied to every anonymous endpoint on the list, not just
register. CI's E2E suite caught the regression: the harness bootstraps an
admin (minting a session cookie) then POSTs /login to re-authenticate,
and the ambient cookie stripped /login of its exemption, producing a
spurious 403 csrf_error.

login/bootstrap/forgot-password/reset-password/local-reset/verify-email/
resend-verification/2fa-login-verify/oauth-login/oauth-link/cli-sessions-
create derive their authority entirely from the request body (credentials,
a token, a shared secret), never from the ambient cookie, and pad mints
the CSRF cookie AT LOGIN — a pre-session endpoint categorically cannot
require a token that doesn't exist yet. Split the allowlist:
authCSRFUnconditionalExemptPaths (everything above, exempt regardless of
cookie) and authCSRFSessionGatedExemptPaths (register only, exempt only
when currentUser(r) == nil, since it alone has a session-privileged
admin-account-creation branch). The round-2 security property (admin
session + register + no CSRF -> still blocked) and round-3's Bearer/
cloud-secret validated-vs-present composite are unaffected.
2026-07-04 11:33:18 -04:00

335 lines
16 KiB
Go

package server
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
)
const (
csrfHeader = "X-CSRF-Token"
csrfTokenLen = 32 // 32 bytes = 64 hex chars
)
// authCSRFUnconditionalExemptPaths lists the exact /api/v1/auth/* paths
// whose authority comes entirely from the request BODY — credentials, a
// one-time token, or a shared secret — never from the ambient session
// cookie (B8, TASK-1932). A cookie riding alongside one of these requests
// doesn't escalate what the handler does, so these stay exempt from the
// double-submit CSRF check REGARDLESS of whether SessionAuth also
// resolved a user for the request.
//
// This isn't just a convenience: pad mints the pad_csrf cookie AT LOGIN
// (createAuthSession), so a pre-session endpoint like /login or
// /bootstrap categorically CANNOT require a CSRF token that doesn't exist
// yet. And a lingering session cookie from an earlier login (an E2E test
// harness re-bootstrapping the admin then logging in fresh, the pad CLI,
// a browser tab that re-authenticates as a different account, or simply
// a stale cookie sitting alongside a fresh login POST) must not turn that
// legitimate re-login into a CSRF failure. codex round 4 (via CI's E2E
// suite, not static review — the gap the unit suite was missing) caught
// exactly this: gating the WHOLE list on currentUser(r) == nil (round 2's
// fix) broke /login whenever an ambient cookie was present, because
// login's authority is the password in the body, not the cookie.
//
// This is an EXACT path match (not a prefix), on purpose: a prefix match is
// exactly the bug B8 flags. In particular:
// - "/api/v1/auth/cli/sessions" (POST, create) is here because it's
// genuinely pre-session (the CLI calls it before any login exists).
// "/api/v1/auth/cli/sessions/{code}/approve" is a DIFFERENT path
// string and is deliberately NOT here — it's a mutating, cookie-
// session-authenticated action (approving a pending CLI login) that a
// cross-site POST could otherwise ride the victim's session to
// trigger. The GET poll endpoint doesn't need an entry — safe methods
// are exempt above regardless of path.
// - "/api/v1/auth/oauth-login" and "/api/v1/auth/oauth-link" are here
// because they authenticate ONLY via a cloud-secret in the JSON body
// (the pad-cloud sidecar calling in server-to-server) and never trust
// a cookie at all — CSRF isn't a meaningful threat model for them, and
// the sidecar has no CSRF cookie to send. "/api/v1/auth/oauth-unlink"
// is deliberately NOT here — unlike link/login, it authenticates
// purely via the session cookie (currentUser(r), no secret check) and
// is exactly the kind of endpoint this hardening targets.
// - "/api/v1/auth/resend-verification" is here because the handler
// itself never inspects the session — it takes an explicit email and
// returns a uniform response regardless of auth state, so an ambient
// cookie carries no extra authority for it and CSRF is a no-op either
// way.
// - "/api/v1/auth/logout" is deliberately NOT here — it's a real,
// cookie-session-authenticated mutation, and the frontend already
// sends CSRF on it via the shared request() wrapper.
// - "/api/v1/auth/register" is deliberately NOT here — see
// authCSRFSessionGatedExemptPaths below.
//
// Requests carrying a Bearer credential that TokenAuth actually VALIDATED
// (a PAT or a CLI session-bearer token) are unaffected by any of this —
// they hit isValidatedBearerAuth's unconditional exemption further down
// this function regardless of path or cookie presence; see that check's
// comment for the codex-round-3 validated-vs-present distinction.
var authCSRFUnconditionalExemptPaths = map[string]bool{
"/api/v1/auth/bootstrap": true,
"/api/v1/auth/login": true,
"/api/v1/auth/forgot-password": true,
"/api/v1/auth/reset-password": true,
"/api/v1/auth/local-reset": true,
"/api/v1/auth/verify-email": true,
"/api/v1/auth/resend-verification": true,
"/api/v1/auth/2fa/login-verify": true,
"/api/v1/auth/oauth-login": true,
"/api/v1/auth/oauth-link": true,
"/api/v1/auth/cli/sessions": true,
}
// authCSRFSessionGatedExemptPaths lists /api/v1/auth/* paths that are
// exempt from CSRF ONLY when the request carries no session — i.e. only
// when currentUser(r) == nil at the call site below. Unlike the
// unconditional set above, these handlers have an ALTERNATE authorization
// branch that DOES read the ambient session cookie, so exempting them
// unconditionally would let a cross-site request ride a victim's cookie
// into that branch with no CSRF token.
//
// register is the only entry: handleRegister is genuinely anonymous most
// of the time (self-serve cloud signup, invitation acceptance), but it
// also has an admin-session branch (an already-logged-in admin can
// create a verified account with no invitation code) — codex round 2
// found that a plain path exemption let a cross-site POST ride the
// admin's cookie straight into that branch with no CSRF token, the same
// class of hole as oauth-unlink. Gating on currentUser(r) == nil closes
// that branch (a session-authenticated register falls through to the
// real double-submit check) while a genuinely anonymous register keeps
// the exemption. This is deliberately its OWN map (not folded into the
// unconditional set with a blanket currentUser guard, which is what
// round 2 originally did and what round 4's CI-caught regression forced
// apart): login/bootstrap/etc. have no session-privileged branch at all,
// so gating them on currentUser(r) == nil was never a security
// requirement — it just broke legitimate re-login-with-a-lingering-cookie
// flows for no benefit.
var authCSRFSessionGatedExemptPaths = map[string]bool{
"/api/v1/auth/register": true,
}
// CSRFProtect implements the double-submit cookie pattern for CSRF protection.
// It validates that state-changing requests (POST, PATCH, PUT, DELETE) from
// cookie-authenticated sessions include a matching CSRF token in both the
// cookie and the X-CSRF-Token header.
//
// Requests authenticated via Bearer tokens (API tokens / CLI) are exempt
// because they are not vulnerable to CSRF attacks — the browser never
// attaches Authorization headers automatically.
//
// Safe methods (GET, HEAD, OPTIONS) are always allowed through.
func (s *Server) CSRFProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods are exempt
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// Non-API paths are exempt (SPA static files, etc.)
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
// Auth endpoints whose authority comes entirely from the request
// body (credentials, a one-time token, a shared secret) — never
// from the ambient session cookie — stay exempt regardless of
// whether SessionAuth also resolved a user for this request. See
// authCSRFUnconditionalExemptPaths for the full per-entry
// rationale and the round-4 CI regression this split fixes
// (B8, TASK-1932).
if authCSRFUnconditionalExemptPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
// register is exempt ONLY when unauthenticated: handleRegister
// has an admin-session branch that DOES derive authority from the
// ambient cookie (codex round 2), so a session-authenticated
// request falls through to the real double-submit check below
// instead of this exemption, while a genuinely anonymous request
// keeps it. See authCSRFSessionGatedExemptPaths's doc comment.
if authCSRFSessionGatedExemptPaths[r.URL.Path] && currentUser(r) == nil {
next.ServeHTTP(w, r)
return
}
// Cloud sidecar calls bypass CSRF because they don't use cookie-
// based sessions; they authenticate via X-Cloud-Secret (or legacy
// ?cloud_secret). Path-gate this explicitly so a stray
// ?cloud_secret= on any other /api/ path (trivial in a cross-site
// form action) cannot be used to defeat CSRF elsewhere.
//
// hasCloudSecretMarker only checks PRESENCE of the marker, not
// whether the secret is actually correct — the real check
// (validateCloudSecret) happens in-handler. codex round 3 caught
// that several of these handlers (e.g. handleSetPlan) ALSO accept
// an admin cookie session as an alternative to the secret
// (`isAdmin := user != nil && ...; if !isAdmin { validateCloudSecret(...) }`)
// — so a marker-presence-only exemption let a cross-site request
// with a garbage X-Cloud-Secret ride a logged-in admin's cookie
// straight past CSRF into that admin branch, the same class of
// hole as handleRegister's in round 2. The currentUser(r) == nil
// guard closes it: a request that also resolved a real session
// falls through to the normal double-submit check below, so the
// admin branch can only be reached with either the real secret or
// a valid CSRF token — never a forged marker riding a stolen
// cookie. The pad-cloud sidecar sends no cookies, so its calls are
// unaffected (currentUser(r) is always nil for them).
if isCloudAdminPath(r.URL.Path) && hasCloudSecretMarker(r) && currentUser(r) == nil {
next.ServeHTTP(w, r)
return
}
// Bearer credentials TokenAuth actually validated (a PAT or a CLI
// session-bearer token, or a synthesized in-process MCP-dispatcher
// call marked via server.WithAPITokenAuth) are never vulnerable to
// CSRF — the browser never attaches Authorization headers
// automatically, and the attacker can't forge the token value.
// Always exempt, regardless of whether a cookie is also present:
// TokenAuth runs before SessionAuth and short-circuits it once
// currentUser is set, so a validated Bearer credential is the
// sole source of truth for currentUser on this request either way.
if isValidatedBearerAuth(r) {
next.ServeHTTP(w, r)
return
}
// An Authorization: Bearer header TokenAuth did NOT validate
// (garbage token, wrong format, expired — anything that hit
// rejectInvalidBearer's public-API-path fallthrough instead of a
// hard 401) is exempt ONLY when no session was also resolved for
// this request. codex round 3: the old unconditional version of
// this check exempted on header PRESENCE alone, so a cross-site
// request carrying a victim's real session cookie plus an
// attacker-supplied garbage Bearer header would sail past CSRF —
// on any /api/v1/auth/* path (isPublicAPIPath prefix-matches the
// whole tree, far wider than the exempt-path maps above), not just
// the newly-tightened B8 endpoints. Gating on currentUser(r) ==
// nil preserves the CLI-recovery contract this fallthrough exists
// for (a pure Bearer client with an expired/garbage token, no
// cookie, must still reach RequireAuth for its own 401 rather than
// a confusing csrf_error) while closing the cookie-riding case.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") && currentUser(r) == nil {
next.ServeHTTP(w, r)
return
}
// No users exist (fresh install) — skip CSRF
count, err := s.store.UserCount()
if err != nil || count == 0 {
next.ServeHTTP(w, r)
return
}
// Cookie-based session: require CSRF token.
//
// Deliberately NO legacy-name fallback here, unlike SessionAuth's
// __Host-pad_session -> pad_session fallback (middleware_auth.go)
// for the session cookie. The two cookies have different trust
// requirements:
// - Session cookie: the token VALUE is an unguessable secret: an
// attacker who can only set the unprefixed name still can't
// forge a value the store will accept. Accepting the legacy
// name is just an upgrade-path convenience.
// - CSRF cookie: double-submit's security property is "the
// attacker cannot set BOTH the cookie and the header" for the
// victim's browser. An unprefixed pad_csrf cookie can be set
// from a sibling subdomain (no __Host- restriction), so an
// attacker who controls a subdomain could plant a
// matching pad_csrf cookie + forge the matching header value
// themselves — defeating the whole scheme. __Host- exists
// specifically to close that subdomain-cookie-injection hole,
// so accepting the unprefixed name in secure mode would
// silently reopen it. Do not "restore symmetry" with
// SessionAuth here; the asymmetry is the point.
//
// Exposure from the lack of fallback is bounded to deployments
// that recently flipped PAD_SECURE_COOKIES on: an already-logged-in
// browser holding legacy pad_session + pad_csrf cookies still
// authenticates (session fallback) but 403s on CSRF-required
// mutations until the next login mints __Host--prefixed cookies —
// self-heals within the session TTL. See
// TestCSRF_LegacyCookieFallback_NotAcceptedWhenSecure.
cookie, err := r.Cookie(csrfCookieName(s.secureCookies))
if err != nil || cookie.Value == "" {
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF token")
return
}
headerToken := r.Header.Get(csrfHeader)
if headerToken == "" {
writeError(w, http.StatusForbidden, "csrf_error", "Missing CSRF header")
return
}
// CSRF tokens are fixed-size hex strings (csrfTokenLen bytes →
// csrfTokenLen*2 hex chars). Reject any token that doesn't
// match the expected length BEFORE allocating. Without this
// an attacker could flood with equally-sized cookie + header
// pairs (within the 64 KiB MaxHeaderBytes cap) and each
// failing request would allocate the []byte copies below
// proportional to the header size — cheap per request, but
// a noticeable GC cost under sustained load. Post-check,
// both values are bounded to csrfTokenLen*2 bytes so the
// allocation is a fixed, tiny cost.
const expectedLen = csrfTokenLen * 2 // hex encoding
if len(cookie.Value) != expectedLen || len(headerToken) != expectedLen {
writeError(w, http.StatusForbidden, "csrf_error", "CSRF token mismatch")
return
}
// subtle.ConstantTimeCompare evaluates the byte compare in time
// independent of where the first differing byte lives, removing
// the timing side-channel that the previous `!=` had.
if subtle.ConstantTimeCompare([]byte(cookie.Value), []byte(headerToken)) != 1 {
writeError(w, http.StatusForbidden, "csrf_error", "CSRF token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// setCSRFCookie writes a new CSRF token cookie. The cookie is NOT HttpOnly
// so that JavaScript can read it and send it back as a header.
func setCSRFCookie(w http.ResponseWriter, ttl int, secure bool) {
token := generateCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName(secure),
Value: token,
Path: "/",
MaxAge: ttl,
HttpOnly: false, // Must be readable by JS
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// clearCSRFCookie removes the CSRF cookie (e.g. on logout).
// Must clear both prefixed and unprefixed names to handle upgrades cleanly.
func clearCSRFCookie(w http.ResponseWriter) {
for _, name := range []string{"pad_csrf", "__Host-pad_csrf"} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
}
}
// generateCSRFToken returns a cryptographically random hex string.
func generateCSRFToken() string {
b := make([]byte, csrfTokenLen)
if _, err := rand.Read(b); err != nil {
panic("csrf: failed to generate random token: " + err.Error())
}
return hex.EncodeToString(b)
}