mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
98871c7e01
* fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382)
Registration mails the verification link to the address, and the address
may belong to someone other than whoever registered it. Clicking it used to
verify the squatter's account in place, keeping the squatter's password and
sessions.
- POST /auth/verify-email now verifies only when the request is signed in as
the account the token names. Otherwise it answers 409 verify_needs_session
with {email, can_claim} and leaves the token unspent.
- New POST /auth/verify-email/claim {token}: the mailbox owner claims a
never-verified account. One transaction spends the token, resets every
credential (an unusable password, 2FA, sessions, PATs, OAuth, CLI
handoffs, reset and verification tokens), marks the address verified,
strips memberships, grants, watches and tabs in workspaces the account
does not own, and soft-deletes the ones it owns (TASK-3351 ruling 3). An
account with a billing customer is refused with
account_claim_needs_support (ruling 4). The answer carries a
set-password path and the affected workspace names.
- users.credential_epoch (SQLite 110 / PG 084): bumped by a password change,
a disable and a claim. Every session mint is fenced on the epoch read
when the credential was checked (login, 2FA via the challenge token, CLI
approval, reset completion, rotation, OAuth login), so a sign-in checked
before a claim mints nothing after it (401 credentials_changed).
- The web verify page offers "Sign in to verify" (back to the link), and,
for an unverified address, a confirmed claim listing what was removed.
- A regression test shows that an unverified account's cookie cannot use
the cookie-fallback routes.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* fix(auth): fence password writes on the checked epoch; own verify bucket; switch account (BUG-3382)
Codex round 1:
- A password change (PATCH /me) or a reset completion that started before
a claim could still overwrite the claim's reset. UserUpdate.ExpectedEpoch
makes the write conditional on the epoch its authority was checked under:
the current password's read, or the reset token's spend. The spend now
reads the epoch in the same statement, and a test hook puts a claim
between the spend and the read-back. A mismatch writes nothing
(credentials_changed).
- verify-email and its claim move to their own EmailVerify bucket (10/hour),
so opening the link, refreshing and claiming no longer spends the
PasswordReset budget the claimant needs to set a password.
- The verify page, signed in as a different account, signs out before
sending the user to sign in. A plain /login link bounced straight back.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
337 lines
16 KiB
Go
337 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/verify-email/claim": true, // BUG-3382: pre-auth, proven by the mailed token
|
|
"/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. A count that cannot be
|
|
// read is not a fresh install, so the check below still runs
|
|
// (BUG-3334).
|
|
if count, err := s.userCount(); 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)
|
|
}
|