mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
67d06fdf81
(a) Sessions did not record how they were issued, and SessionAuth accepted
any padsess_ token from a cookie as a cookie session. isBearerAuth is
decided by transport, so an admin's CLI session sent as a Cookie picked up
the cookie-only platform-admin bypass that bearer use is denied (BUG-1616).
- Migration 105/079 adds sessions.kind ('web' | 'cli'). It backfills 'cli'
from device_info 'cli-browser-auth' (lead's ruling: no forced re-login;
older CLI password logins stay 'web' until they expire).
- The kind comes from the device label at creation.
- The CLI sends X-Pad-Client: cli, so its password and 2FA sign-ins mint a
'cli' session and set no cookie. Rotation keeps the replaced session's
kind.
- SessionAuth, validateSessionCookie and requestSessionInfo's cookie path
accept only a 'web' session as a cookie. A CLI session still works as
the bearer it is.
(b) /auth/login (and 2FA login-verify) were CSRF-exempt and decoded JSON
whatever the Content-Type, so a cross-site text/plain form could sign the
victim into the attacker's account. A sign-in must now be
application/json (415 otherwise), and an Origin the browser names must be
this server (its Host or base URL) or an allowed CORS origin, including the
localhost defaults the dev server uses (403 otherwise). Headless clients
send no Origin. An E2E leg pins the web app's own login form sending JSON.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
1377 lines
57 KiB
Go
1377 lines
57 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
type contextKey string
|
|
|
|
const (
|
|
// ctxTokenWorkspaceID is set when a valid API token is present.
|
|
ctxTokenWorkspaceID contextKey = "token_workspace_id"
|
|
// ctxCurrentUser is set when an authenticated user is resolved.
|
|
ctxCurrentUser contextKey = "current_user"
|
|
// ctxWorkspaceRole is set by RequireWorkspaceAccess with the user's role.
|
|
ctxWorkspaceRole contextKey = "workspace_role"
|
|
// ctxIsAPIToken is set to true when the request is authenticated via an API token
|
|
// (as opposed to a session cookie or CLI session token).
|
|
ctxIsAPIToken contextKey = "is_api_token"
|
|
// ctxChatGPTSurface marks a request the ChatGPT MCP catalog dispatched
|
|
// in-process (TASK-3321 U1b). Only Go code in this process can set it;
|
|
// no header or body reaches it.
|
|
ctxChatGPTSurface contextKey = "chatgpt_surface"
|
|
// ctxTokenScopes carries the JSON-encoded scopes string from the
|
|
// validated API token (e.g. `["read"]`, `["*"]`). Stashed by
|
|
// MCPBearerAuth so the in-process MCP dispatcher can re-check
|
|
// scopes per synthesized tool call (the dispatcher bypasses
|
|
// TokenAuth's chain-level scope check by setting WithCurrentUser
|
|
// directly). See WithTokenScopes / TokenScopesFromContext in
|
|
// context.go and the per-tool gate in internal/mcp/dispatch_http.go.
|
|
ctxTokenScopes contextKey = "token_scopes"
|
|
// ctxResolvedWorkspaceID is set by RequireWorkspaceAccess after resolving
|
|
// the workspace slug/ID. Avoids redundant lookups in handlers.
|
|
ctxResolvedWorkspaceID contextKey = "resolved_workspace_id"
|
|
// ctxTokenAllowedWorkspaces carries the OAuth token's workspace
|
|
// allow-list set at consent time (TASK-952). Either a list of
|
|
// slugs or `["*"]` (wildcard). Read by RequireWorkspaceAccess to
|
|
// reject requests against workspaces the user didn't include in
|
|
// the consent (TASK-953). nil → no token-level workspace
|
|
// constraint (PAT auth, or pre-TASK-952 OAuth tokens).
|
|
ctxTokenAllowedWorkspaces contextKey = "token_allowed_workspaces"
|
|
// ctxMCPTokenKind / ctxMCPTokenRef carry the bearer's identity for
|
|
// MCP audit logging (TASK-960). Stashed by MCPBearerAuth so the
|
|
// audit middleware (middleware_mcp_audit.go) can record which
|
|
// connection (OAuth request_id chain) or which PAT (api_tokens.id)
|
|
// drove the call. Empty when no MCP-auth happened (i.e. the request
|
|
// didn't go through MCPBearerAuth). The ref values aren't sensitive
|
|
// — they're internal IDs / chain identifiers, never the raw bearer
|
|
// — so leaking via context is fine.
|
|
ctxMCPTokenKind contextKey = "mcp_token_kind"
|
|
ctxMCPTokenRef contextKey = "mcp_token_ref"
|
|
// ctxValidatedSessionBearer is set to true when the request was
|
|
// authenticated via a validated CLI session-bearer token
|
|
// (Authorization: Bearer padsess_...), as distinct from ctxIsAPIToken
|
|
// (a long-lived PAT) and from a session cookie. Deliberately a
|
|
// separate key rather than reusing ctxIsAPIToken: ctxIsAPIToken also
|
|
// gates PAT-vs-interactive-session behavior elsewhere (e.g. 2FA
|
|
// management requires an interactive session and explicitly rejects
|
|
// isAPITokenAuth callers) — a CLI session-bearer token IS an
|
|
// interactive session and must not trip that gate the way a PAT
|
|
// does. CSRFProtect reads this (via isValidatedBearerAuth) to tell
|
|
// "TokenAuth validated this Bearer credential" apart from "the
|
|
// request merely carries an Authorization: Bearer header TokenAuth
|
|
// never checked or rejected" — see the codex-round-3 fix note there
|
|
// (TASK-1932).
|
|
ctxValidatedSessionBearer contextKey = "validated_session_bearer"
|
|
|
|
// HOW the principal on this request was established (BUG-3007).
|
|
//
|
|
// Set exactly once, by the middleware branch that accepts the
|
|
// credential, and read by `streamCredentialStillValid` so a long-lived
|
|
// connection can re-check the SAME KIND of credential it was opened
|
|
// with. Sniffing the wire a second time would be a second place to get
|
|
// the session-bearer-versus-PAT split wrong; this makes the middleware
|
|
// the single authority on what the credential is.
|
|
//
|
|
// The predicate's default branch fails CLOSED, so a future auth path
|
|
// that forgets to set this ends long-lived connections loudly rather
|
|
// than exempting itself from revalidation. That direction is
|
|
// deliberate: an unrecognised credential that keeps streaming is the
|
|
// same defect BUG-3007 fixed, wearing a different coat.
|
|
ctxAuthKind contextKey = "auth_kind"
|
|
)
|
|
|
|
// Credential kinds recorded in ctxAuthKind. See the key's comment.
|
|
const (
|
|
authKindSessionBearer = "session_bearer"
|
|
authKindSessionCookie = "session_cookie"
|
|
authKindAPIToken = "api_token"
|
|
)
|
|
|
|
// authKind returns how this request's principal was established, or "" when no
|
|
// credential was accepted — the fresh-install window and the legacy no-auth
|
|
// path both land there.
|
|
func authKind(r *http.Request) string {
|
|
if k, ok := r.Context().Value(ctxAuthKind).(string); ok {
|
|
return k
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// TokenAuth middleware checks for an Authorization: Bearer pad_xxx header.
|
|
// If a valid token is found, the associated workspace ID is stored in the
|
|
// request context. If no token header is present the request passes through
|
|
// unchanged (existing localhost behaviour). Invalid or expired tokens
|
|
// receive a 401 response — UNLESS the request targets a path in
|
|
// isPublicAPIPath (login, /auth/session, password reset, share links,
|
|
// health), in which case the request falls through unauthenticated so
|
|
// the caller can recover from a stale token. Without that fallthrough a
|
|
// developer with a stale `~/.pad/credentials.json` (e.g. after wiping a
|
|
// test DB) would see "Invalid or expired session" on every CLI command,
|
|
// including the very endpoints needed to log in again. See BUG-1227 and
|
|
// the matching IP-change-revoked branch below for the same pattern.
|
|
func (s *Server) TokenAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if auth == "" {
|
|
// No token provided — allow through (localhost access).
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Expect "Bearer pad_<64 hex chars>" or "Bearer padsess_<64 hex chars>"
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid authorization header format")
|
|
return
|
|
}
|
|
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
token = strings.TrimSpace(token)
|
|
|
|
// Session token (from CLI login)
|
|
if strings.HasPrefix(token, "padsess_") {
|
|
session, err := s.store.ValidateSession(token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Session validation failed")
|
|
return
|
|
}
|
|
if session == nil {
|
|
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid or expired session")
|
|
return
|
|
}
|
|
// Session binding: detect a User-Agent change. Logged in all
|
|
// modes; the session is revoked + the request rejected only when
|
|
// PAD_IP_CHANGE_ENFORCE=strict. The UA is client-supplied and
|
|
// trivially replayed by anyone who already stole the token, so it
|
|
// stays log-only by default (breaking browser/WebView updates,
|
|
// DevTools device emulation, and mobile-app rebuilds would hurt
|
|
// more than it helps). Checked BEFORE the IP change so a stolen
|
|
// token replayed from a fresh client is killed on the more stable
|
|
// of the two signals first.
|
|
switch s.handleSessionUAChange(w, r, session, token) {
|
|
case sessionIPChangeTerminated:
|
|
return
|
|
case sessionIPChangeRevoked:
|
|
if isPublicAPIPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "session_ua_changed",
|
|
"Session client changed — please log in again.")
|
|
return
|
|
}
|
|
// Session binding: detect IP changes. Log to audit log in all modes;
|
|
// reject only when PAD_IP_CHANGE_ENFORCE=strict.
|
|
switch s.handleSessionIPChange(w, r, session, token) {
|
|
case sessionIPChangeTerminated:
|
|
return
|
|
case sessionIPChangeRevoked:
|
|
// Session was destroyed. For public API paths (login,
|
|
// password reset, health, share links) pass through
|
|
// unauthenticated so the caller can recover. For
|
|
// authenticated-only paths, the Bearer flow has no SPA
|
|
// fallback — treat revocation as a hard 401.
|
|
if isPublicAPIPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, "session_ip_changed",
|
|
"Session client IP changed — please log in again.")
|
|
return
|
|
}
|
|
// Sliding renewal: extend the session's expiry on activity so an
|
|
// actively-used token never hits the fixed TTL cliff. Bearer
|
|
// clients hold the token directly (no cookie to re-issue) — just
|
|
// push the row's expiry forward. Best-effort: a renewal error must
|
|
// not fail an otherwise-valid request.
|
|
if _, _, err := s.store.RenewSessionIfStale(token); err != nil {
|
|
slog.Warn("session renewal failed (request allowed)", "error", err)
|
|
}
|
|
ctx := context.WithValue(r.Context(), ctxCurrentUser, session.User)
|
|
ctx = context.WithValue(ctx, ctxValidatedSessionBearer, true)
|
|
ctx = context.WithValue(ctx, ctxAuthKind, authKindSessionBearer)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// API token
|
|
if !strings.HasPrefix(token, "pad_") || len(token) != 68 {
|
|
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid token format")
|
|
return
|
|
}
|
|
|
|
apiToken, err := s.store.ValidateToken(token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed")
|
|
return
|
|
}
|
|
if apiToken == nil {
|
|
rejectInvalidBearer(w, r, next, "unauthorized", "Invalid or expired token")
|
|
return
|
|
}
|
|
|
|
// Enforce token scopes
|
|
if !tokenScopeAllows(apiToken.Scopes, r.Method, r.URL.Path) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "Token scope does not permit this action")
|
|
return
|
|
}
|
|
|
|
// Add near-expiry warning headers
|
|
setTokenExpiryWarning(w, apiToken)
|
|
|
|
ctx := context.WithValue(r.Context(), ctxIsAPIToken, true)
|
|
ctx = context.WithValue(ctx, ctxAuthKind, authKindAPIToken)
|
|
// Stash the token's scopes so downstream handlers can enforce
|
|
// write-scope on paths where the method gate above isn't
|
|
// sufficient. The chain-level tokenScopeAllows check keys on the
|
|
// HTTP method, which is right for ordinary REST verbs; but the
|
|
// collab WebSocket upgrade is a GET that goes on to PERSIST Yjs
|
|
// mutations, so its handler re-checks write capability from these
|
|
// scopes (mirrors what MCPBearerAuth already stashes). Per TASK-265.
|
|
ctx = WithTokenScopes(ctx, apiToken.Scopes)
|
|
|
|
// Resolve user from token's user_id (new user-owned tokens). A
|
|
// user-owned token is never downgraded to a user-less one (BUG-3353):
|
|
// the request would otherwise carry only the token's workspace and
|
|
// reach every route a legacy workspace token reaches, with nobody's
|
|
// authority. A user that cannot be read is a 500; one that is gone,
|
|
// a 401.
|
|
if apiToken.UserID != "" {
|
|
user, err := s.store.GetUser(apiToken.UserID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Token validation failed")
|
|
return
|
|
}
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, ctxCurrentUser, user)
|
|
}
|
|
|
|
// Store workspace ID from the token if workspace-scoped
|
|
if apiToken.WorkspaceID != "" {
|
|
ctx = context.WithValue(ctx, ctxTokenWorkspaceID, apiToken.WorkspaceID)
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// SessionAuth middleware resolves session cookies into authenticated users.
|
|
// If a user was already resolved by TokenAuth, this is a no-op.
|
|
func (s *Server) SessionAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Already authenticated by TokenAuth — user-bound API tokens set
|
|
// currentUser, legacy workspace-scoped tokens set tokenWorkspaceID
|
|
// with no user. In either case we must short-circuit: otherwise a
|
|
// stale session cookie on the same request could trigger the
|
|
// IP-change strict-mode path and 401 the request even though the
|
|
// API token itself is valid.
|
|
if currentUser(r) != nil || tokenWorkspaceID(r) != "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Try session cookie (with fallback to unprefixed name for upgrade path)
|
|
cookie, err := r.Cookie(sessionCookieName(s.secureCookies))
|
|
if err != nil {
|
|
cookie, err = r.Cookie("pad_session")
|
|
if err != nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
session, err := s.store.ValidateSession(cookie.Value)
|
|
// A CLI session is a bearer credential, never a cookie (BUG-3350).
|
|
if err != nil || session == nil || !cookieKindAccepted(session) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Session binding: detect a User-Agent change. Logged in all modes;
|
|
// the session is revoked only when PAD_IP_CHANGE_ENFORCE=strict. See
|
|
// the matching note in TokenAuth above — UA is client-supplied, weak
|
|
// as a binding, and false-positives on routine client churn
|
|
// (browser/WebView updates, DevTools device emulation, mobile-app
|
|
// rebuilds), so it stays log-only by default.
|
|
switch s.handleSessionUAChange(w, r, session, cookie.Value) {
|
|
case sessionIPChangeTerminated:
|
|
return
|
|
case sessionIPChangeRevoked:
|
|
// Browser path: let the request through unauthenticated so
|
|
// the SPA renders its login flow instead of a JSON error.
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Session binding: detect IP changes. Log to audit log in all modes;
|
|
// reject only when PAD_IP_CHANGE_ENFORCE=strict.
|
|
switch s.handleSessionIPChange(w, r, session, cookie.Value) {
|
|
case sessionIPChangeTerminated:
|
|
return
|
|
case sessionIPChangeRevoked:
|
|
// Browser path: let the request through unauthenticated so
|
|
// the SPA renders its login flow instead of a JSON error.
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Sliding renewal: extend the session's expiry on activity and re-issue
|
|
// the session cookie (plus CSRF, to keep their lifetimes in sync) with a
|
|
// fresh Max-Age so an actively-used session never expires out from under
|
|
// the user at the fixed TTL. Best-effort: a renewal error must not fail
|
|
// an otherwise-valid request. When this re-issues the CSRF cookie it
|
|
// also covers the "missing CSRF cookie" case below.
|
|
renewed := false
|
|
if newExpiry, didRenew, err := s.store.RenewSessionIfStale(cookie.Value); err != nil {
|
|
slog.Warn("session renewal failed (request allowed)", "error", err)
|
|
} else if didRenew {
|
|
if maxAge := int(time.Until(newExpiry).Seconds()); maxAge > 0 {
|
|
setSessionCookie(w, cookie.Value, maxAge, s.secureCookies)
|
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
|
|
setCSRFCookie(w, maxAge, s.secureCookies)
|
|
}
|
|
renewed = true
|
|
}
|
|
}
|
|
|
|
// Re-issue CSRF cookie if the session is valid but the cookie is missing.
|
|
// This can happen when cookies expire at different times or are selectively cleared.
|
|
// Skip for auth endpoints — they manage their own CSRF cookies (login sets, logout clears).
|
|
// Skip when sliding renewal already re-issued the CSRF cookie above.
|
|
if !renewed && !strings.HasPrefix(r.URL.Path, "/api/v1/auth/") {
|
|
if _, csrfErr := r.Cookie(csrfCookieName(s.secureCookies)); csrfErr != nil {
|
|
setCSRFCookie(w, 7*24*60*60, s.secureCookies)
|
|
}
|
|
}
|
|
|
|
ctx := context.WithValue(r.Context(), ctxCurrentUser, session.User)
|
|
ctx = context.WithValue(ctx, ctxAuthKind, authKindSessionCookie)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// rejectInvalidBearer writes a 401 with the given code/message UNLESS
|
|
// the request targets a path in isPublicAPIPath, in which case it falls
|
|
// through to the next handler unauthenticated. The fallthrough is the
|
|
// recovery hatch that lets a stale Bearer token still reach
|
|
// /api/v1/auth/session, /login, /forgot-password, etc. — without it a
|
|
// stale `~/.pad/credentials.json` (e.g. after wiping a test DB) would
|
|
// 401 every CLI command, including the ones needed to recover. Mirrors
|
|
// the existing isPublicAPIPath fallthrough in the IP-change-revoked
|
|
// branch (BUG-1227).
|
|
func rejectInvalidBearer(w http.ResponseWriter, r *http.Request, next http.Handler, code, message string) {
|
|
if isPublicAPIPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeError(w, http.StatusUnauthorized, code, message)
|
|
}
|
|
|
|
// isPublicAPIPath reports whether the given request path is an API
|
|
// endpoint that bypasses authentication entirely. These handlers must
|
|
// work even when the caller has no valid session (login, registration,
|
|
// password reset, health probes, public plan limits, share-link tokens).
|
|
// Shared between RequireAuth and the session-IP-change path so both stay
|
|
// in sync — in particular, strict IP-change enforcement must NOT 401
|
|
// these endpoints, otherwise a user with a stale cookie can't even
|
|
// recover by logging in again.
|
|
func isPublicAPIPath(path string) bool {
|
|
return strings.HasPrefix(path, "/api/v1/auth/") ||
|
|
path == "/api/v1/health" ||
|
|
strings.HasPrefix(path, "/api/v1/health/") ||
|
|
strings.HasPrefix(path, "/api/v1/s/") ||
|
|
path == "/api/v1/plan-limits" ||
|
|
// Non-consuming invitation preview (BUG-1934). A logged-out invitee
|
|
// hits /join/{code} before authenticating, and the page fetches the
|
|
// invited email + workspace name to prefill the form. Read-only and
|
|
// always-200 (enumeration-safe); the accept path (/accept) stays
|
|
// auth-gated. Matches only the trailing /preview segment so
|
|
// /invitations/{code}/accept is unaffected.
|
|
(strings.HasPrefix(path, "/api/v1/invitations/") && strings.HasSuffix(path, "/preview")) ||
|
|
// Server capability profile (TASK-878). The editor reads this
|
|
// before login on shared-item preview surfaces, and the
|
|
// handler is intentionally read-only (no DB writes, no
|
|
// per-user state). Keeping it public matches the route's
|
|
// register-time intent in server.go.
|
|
path == "/api/v1/server/capabilities"
|
|
}
|
|
|
|
// legacyTokenRoutes are the global routes a user-less legacy workspace token
|
|
// may use (BUG-3353). Each is either confined to the token's workspace by its
|
|
// own handler, or serves static content.
|
|
var legacyTokenRoutes = map[string]string{
|
|
"/api/v1/workspaces": http.MethodGet, // its own workspace only (handleListWorkspaces)
|
|
"/api/v1/search": "", // searchFanOutWorkspaces: the token's workspace
|
|
"/api/v1/events": "", // refuses any workspace but the token's
|
|
"/api/v1/templates": http.MethodGet,
|
|
"/api/v1/convention-library": http.MethodGet,
|
|
"/api/v1/playbook-library": http.MethodGet,
|
|
"/api/v1/library/entry": http.MethodGet,
|
|
"/api/v1/mcp/tool-surface": http.MethodGet,
|
|
}
|
|
|
|
// legacyWorkspaceSegmentsRefused are the names under /api/v1/workspaces/ that
|
|
// are global routes, not a workspace slug.
|
|
var legacyWorkspaceSegmentsRefused = map[string]bool{
|
|
"reorder": true, "import": true, "deleted": true,
|
|
}
|
|
|
|
// legacyTokenRouteAllowed reports whether a user-less legacy workspace token
|
|
// may use r's route. Workspace-scoped routes are allowed, because
|
|
// RequireWorkspaceAccess pins the token to its own workspace there, and so is
|
|
// a page the SPA serves. The collab socket checks the item's workspace
|
|
// against the token's. Everything else is refused.
|
|
func legacyTokenRouteAllowed(r *http.Request) bool {
|
|
path := r.URL.Path
|
|
if !strings.HasPrefix(path, "/api/") {
|
|
return true
|
|
}
|
|
if method, ok := legacyTokenRoutes[path]; ok {
|
|
return method == "" || method == r.Method
|
|
}
|
|
if strings.HasPrefix(path, "/api/v1/collab/") {
|
|
return true
|
|
}
|
|
if rest, ok := strings.CutPrefix(path, "/api/v1/workspaces/"); ok {
|
|
slug, tail, _ := strings.Cut(rest, "/")
|
|
if slug == "" || legacyWorkspaceSegmentsRefused[slug] {
|
|
return false
|
|
}
|
|
// Restore addresses a DELETED workspace, so RequireWorkspaceAccess
|
|
// does not run on it.
|
|
return tail != "restore"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// disabledAccountPathAllowed is what a disabled account may still do: sign
|
|
// out, and ask for its session state (which reports account_disabled).
|
|
func disabledAccountPathAllowed(r *http.Request) bool {
|
|
switch r.URL.Path {
|
|
case "/api/v1/auth/logout":
|
|
return r.Method == http.MethodPost
|
|
case "/api/v1/auth/session":
|
|
return r.Method == http.MethodGet
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RequireAuth middleware blocks unauthenticated requests when users exist
|
|
// in the system. When no users exist (fresh install), all requests pass
|
|
// through to allow the setup flow.
|
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
// A disabled account is refused BEFORE the public exemption
|
|
// (BUG-3349): /api/v1/auth/* is public, so a disabled user's surviving
|
|
// credential used to reach /auth/export and the rest of it. Signing
|
|
// out, and the session check that says why, stay open.
|
|
if user := currentUser(r); user != nil && user.IsDisabled() && !disabledAccountPathAllowed(r) {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
|
|
// Auth endpoints, share link resolution, health, and the public
|
|
// plan-limits endpoint are always exempt from auth.
|
|
if isPublicAPIPath(path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Cloud sidecar endpoints authenticate via cloud_secret (X-Cloud-Secret
|
|
// header, or legacy ?cloud_secret query-param). Only bypass the auth
|
|
// gate when ALL of these hold:
|
|
// 1. The request path is one of the cloud admin endpoints.
|
|
// 2. The request carries a cloud-secret marker.
|
|
// 3. No session was already resolved for this request (currentUser
|
|
// == nil) — see below.
|
|
// The path gate is critical — without it, setting X-Cloud-Secret on
|
|
// any route (e.g. GET /api/v1/workspaces) would globally bypass auth.
|
|
//
|
|
// hasCloudSecretMarker only checks PRESENCE of the marker, not
|
|
// whether the secret is actually correct — sibling of the two
|
|
// presence-only holes TASK-1932 closed in CSRFProtect (BUG-1944).
|
|
// Mirroring that fix's currentUser(r) == nil gate: a request that
|
|
// SessionAuth already resolved to a real user falls through to the
|
|
// normal auth path below instead of taking this early exit.
|
|
//
|
|
// Concretely, this matters even for a marker with the WRONG secret:
|
|
// every cloudAdminPaths handler trusts a resolved admin session as
|
|
// an alternative to validateCloudSecret (`isAdmin := user != nil &&
|
|
// user.Role == "admin"; if !isAdmin { validateCloudSecret(...) }`).
|
|
// Without this gate, a DISABLED admin whose session cookie hasn't
|
|
// been revoked could attach a garbage X-Cloud-Secret marker (plus a
|
|
// valid CSRF token) and skip straight past this middleware's
|
|
// user.IsDisabled() check further down — reaching a handler that
|
|
// still trusts their stale session as admin. Requiring currentUser
|
|
// == nil here forces that request through the normal
|
|
// currentUser != nil branch below, which does enforce IsDisabled.
|
|
//
|
|
// This bypass is NOT a substitute for handler-level validation —
|
|
// requireCloudMode (route-level) + validateCloudSecret
|
|
// (handler-level) still confirm cloud mode is actually on and that
|
|
// the secret matches for the currentUser == nil (genuine sidecar)
|
|
// case this bypass exists for. Do not remove those in-handler
|
|
// checks on the strength of this gate; the layers are meant to
|
|
// stay independent.
|
|
if isCloudAdminPath(path) && hasCloudSecretMarker(r) && currentUser(r) == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Static assets are exempt
|
|
if strings.HasPrefix(path, "/_app/") ||
|
|
path == "/favicon.ico" ||
|
|
strings.HasSuffix(path, ".png") ||
|
|
strings.HasSuffix(path, ".svg") ||
|
|
strings.HasSuffix(path, ".ico") ||
|
|
strings.HasSuffix(path, ".webmanifest") ||
|
|
strings.HasSuffix(path, ".json") && !strings.HasPrefix(path, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// If no users exist, allow everything (fresh install / setup mode).
|
|
// Only a count READ as zero is an empty instance: a count that
|
|
// cannot be read falls through to the authentication check below, so
|
|
// a signed-in caller keeps working and an anonymous one is refused
|
|
// (BUG-3334).
|
|
if count, err := s.userCount(); err == nil && count == 0 {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Already authenticated via token or session
|
|
if user := currentUser(r); user != nil {
|
|
if user.IsDisabled() {
|
|
writeError(w, http.StatusForbidden, "account_disabled", "Your account has been disabled. Contact an administrator.")
|
|
return
|
|
}
|
|
// Use a short-lived context so the write is cancelled if the DB is slow,
|
|
// preventing goroutine/connection buildup under load. Tracked via
|
|
// s.goAsync so Stop() can drain it before the DB is closed (BUG-842).
|
|
s.goAsync(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
s.store.TouchUserActivity(ctx, user.ID)
|
|
})
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if tokenWorkspaceID(r) != "" {
|
|
// A user-less legacy workspace token carries one workspace and no
|
|
// one's authority, so it is admitted only where its reach is that
|
|
// workspace (BUG-3353). Deny by default: a route added later is
|
|
// refused until someone decides it is safe for such a token, and
|
|
// TestBUG3353_EveryGlobalRouteRefusesALegacyToken walks the
|
|
// router to hold that.
|
|
if !legacyTokenRouteAllowed(r) {
|
|
writeError(w, http.StatusForbidden, "legacy_token_not_allowed",
|
|
"This workspace token cannot be used here. Use a personal access token or sign in.")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Unauthenticated — return 401 for API, let SPA handle for browser
|
|
if strings.HasPrefix(path, "/api/") {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
// For browser page requests, serve the SPA (frontend will check auth and show login)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// tokenWorkspaceID returns the workspace ID set by the TokenAuth middleware,
|
|
// or an empty string if no token was used.
|
|
func tokenWorkspaceID(r *http.Request) string {
|
|
v, _ := r.Context().Value(ctxTokenWorkspaceID).(string)
|
|
return v
|
|
}
|
|
|
|
// currentUser returns the authenticated user from the request context,
|
|
// or nil if no user is authenticated.
|
|
func currentUser(r *http.Request) *models.User {
|
|
u, _ := r.Context().Value(ctxCurrentUser).(*models.User)
|
|
return u
|
|
}
|
|
|
|
// isAPITokenAuth returns true if the request was authenticated via an API token
|
|
// (not a session cookie or CLI session). Use this to gate sensitive operations
|
|
// like 2FA enrollment that should require an interactive session.
|
|
func isAPITokenAuth(r *http.Request) bool {
|
|
v, _ := r.Context().Value(ctxIsAPIToken).(bool)
|
|
return v
|
|
}
|
|
|
|
// isValidatedSessionBearerAuth returns true if the request was authenticated
|
|
// via a validated CLI session-bearer token (Authorization: Bearer
|
|
// padsess_...) — set by TokenAuth only on successful ValidateSession, never
|
|
// on the rejectInvalidBearer fallthrough. See ctxValidatedSessionBearer.
|
|
func isValidatedSessionBearerAuth(r *http.Request) bool {
|
|
v, _ := r.Context().Value(ctxValidatedSessionBearer).(bool)
|
|
return v
|
|
}
|
|
|
|
// isValidatedBearerAuth reports whether the request was authenticated via a
|
|
// Bearer credential TokenAuth actually validated — a long-lived API token
|
|
// (isAPITokenAuth) or a CLI session-bearer token
|
|
// (isValidatedSessionBearerAuth) — as opposed to merely carrying an
|
|
// Authorization: Bearer header that TokenAuth never checked, or checked and
|
|
// rejected (the rejectInvalidBearer fallthrough on public API paths). Both
|
|
// validated forms are unguessable secrets a CSRF attacker can't forge, so
|
|
// CSRFProtect treats them as exempt regardless of whether a session cookie
|
|
// is also present on the same request (TASK-1932, codex round 3).
|
|
func isValidatedBearerAuth(r *http.Request) bool {
|
|
return isAPITokenAuth(r) || isValidatedSessionBearerAuth(r)
|
|
}
|
|
|
|
// isBearerAuth reports whether the request was authenticated via an
|
|
// Authorization: Bearer header rather than a browser session cookie.
|
|
// True for: PATs on /api/v1, PATs on /mcp, OAuth bearers on /mcp, AND
|
|
// CLI session-bearer tokens (padsess_* delivered via Authorization:
|
|
// Bearer, see TokenAuth's session-bearer branch at line 92-132).
|
|
// False for: cookie-borne SessionAuth (the web UI / SPA path).
|
|
//
|
|
// Mirrors the dual signal used by middleware_csrf.go (line 73-80) —
|
|
// either an Authorization: Bearer header on the live request OR a
|
|
// ctxIsAPIToken stash on context counts as "non-browser caller."
|
|
//
|
|
// Used by RequireWorkspaceAccess (BUG-1616) to suppress the
|
|
// platform-admin global bypass: admin identity carries owner-level
|
|
// authority over every workspace ONLY for the cookie-borne web UI
|
|
// surface. Bearer-borne callers (CLI, PAT, MCP) get the strict
|
|
// workspace_members check — a leaked admin token, or an MCP client
|
|
// that picked "all current workspaces" at consent time, must not
|
|
// silently widen access from "workspaces the admin joined" to
|
|
// "every workspace on the server."
|
|
func isBearerAuth(r *http.Request) bool {
|
|
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
|
|
return true
|
|
}
|
|
return isAPITokenAuth(r)
|
|
}
|
|
|
|
// currentUserID returns the authenticated user's ID, or empty string.
|
|
func currentUserID(r *http.Request) string {
|
|
if u := currentUser(r); u != nil {
|
|
return u.ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RequireWorkspaceAccess checks that the current user is a member of the
|
|
// workspace identified by the {slug} URL parameter. The user's workspace
|
|
// role is stored in the request context for downstream permission checks.
|
|
//
|
|
// When no users exist (fresh install), access is granted with an implicit
|
|
// "owner" role. Legacy API tokens (workspace-scoped, no user) are allowed
|
|
// if the token's workspace matches the requested workspace.
|
|
func (s *Server) RequireWorkspaceAccess(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
slugOrID := chi.URLParam(r, "slug")
|
|
if slugOrID == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Resolve workspace: supports both UUID and slug.
|
|
ws, err := s.resolveWorkspace(slugOrID, currentUser(r))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to resolve workspace")
|
|
return
|
|
}
|
|
if ws == nil {
|
|
writeWorkspaceNotFound(w, "Workspace not found")
|
|
return
|
|
}
|
|
|
|
// OAuth token allow-list gate (TASK-953). The consent UI
|
|
// (TASK-952) lets the user pick which workspaces a token
|
|
// can access; MCPBearerAuth stashes that list in context
|
|
// via WithTokenAllowedWorkspaces. Reject any request hitting
|
|
// a workspace outside the list, even if the user is a
|
|
// member of it — the user explicitly chose not to grant the
|
|
// app that access.
|
|
//
|
|
// nil → no token-level constraint (PAT auth, or pre-TASK-952
|
|
// OAuth tokens that predate the consent UI). Wildcard `["*"]`
|
|
// → grant access to any membership the user has. Else: the
|
|
// resolved workspace's slug MUST appear in the list.
|
|
//
|
|
// Compares against the canonical slug (ws.Slug) because the
|
|
// consent UI persists slugs and the URL slugOrID may be a
|
|
// UUID which resolveWorkspace just translated. Slug-vs-slug
|
|
// is the right comparison.
|
|
if !tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) {
|
|
// TASK-961: count workspace-allow-list denials so the
|
|
// MCP dashboard can flag tokens hitting workspaces outside
|
|
// their consent scope. Gated on MCP-origin requests only
|
|
// (presence of MCP token identity in context) so non-MCP
|
|
// /api/v1 traffic — which can't even reach this gate
|
|
// today, but might in a future PAT-with-allow-list world
|
|
// — doesn't pollute the MCP-specific counter.
|
|
s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist")
|
|
writeError(w, http.StatusForbidden, "permission_denied",
|
|
"Token is not authorized for this workspace")
|
|
return
|
|
}
|
|
|
|
// Store resolved workspace ID in context for downstream handlers
|
|
ctx := context.WithValue(r.Context(), ctxResolvedWorkspaceID, ws.ID)
|
|
|
|
// Fresh install: no users → everyone gets owner access. A count that
|
|
// cannot be read never grants it (BUG-3334); the request goes on to
|
|
// the principal checks below.
|
|
if count, err := s.userCount(); err == nil && count == 0 {
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner")
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
// Legacy API token: workspace-scoped token without user context
|
|
if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" && currentUser(r) == nil {
|
|
if tokenWsID == ws.ID {
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, "editor")
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
// The same answer as a workspace that does not exist (BUG-3353):
|
|
// a 403 here told a workspace token which slugs are real.
|
|
writeWorkspaceNotFound(w, "Workspace not found")
|
|
return
|
|
}
|
|
|
|
// Authenticated user: check workspace membership
|
|
user := currentUser(r)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
// Admin users get owner access to all workspaces — but ONLY for
|
|
// browser cookie session auth (the web UI / SPA surface). For
|
|
// every bearer-borne path (PATs on /api/v1, PATs and OAuth
|
|
// bearers on /mcp, AND padsess_* session bearers from the CLI),
|
|
// the platform-admin global bypass is suppressed and the admin
|
|
// falls through to the membership-only check below.
|
|
//
|
|
// Rationale (BUG-1616): a token — including a CLI session
|
|
// bearer — carries the user's identity, not the user's
|
|
// platform-admin authority. The OAuth consent UI's "All
|
|
// current workspaces" option, and the CLI's general affordance
|
|
// of "log in once and address every workspace by slug," are
|
|
// both meant to mean "every workspace the user is a member of"
|
|
// — not "every workspace on the entire server, by virtue of
|
|
// the user holding the platform admin role." A leaked admin
|
|
// token, an MCP client that picked the wide consent scope, or
|
|
// even a CLI session running on a compromised machine, would
|
|
// otherwise reach data the admin never joined.
|
|
//
|
|
// The cookie-borne web UI keeps the bypass because the UI
|
|
// shows the admin's chosen workspace explicitly and the
|
|
// /console/admin surface is the documented spot to act
|
|
// cross-workspace; the deliberate UI affordance + same-origin
|
|
// cookie binding make that a different threat model from the
|
|
// programmatic surfaces.
|
|
//
|
|
// isBearerAuth folds two signals (Authorization: Bearer header
|
|
// OR ctxIsAPIToken stash) so MCP-dispatcher synthesized
|
|
// requests and CLI session bearers are both covered — see
|
|
// the helper's comment for the full table.
|
|
isBearer := isBearerAuth(r)
|
|
if user.Role == "admin" && !isBearer {
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, "owner")
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
member, err := s.store.GetWorkspaceMember(ws.ID, user.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check workspace access")
|
|
return
|
|
}
|
|
if member == nil {
|
|
// Admin-via-bearer (BUG-1616): membership-only. Skip the
|
|
// guest-grants fallback so a platform admin who happens to
|
|
// hold a guest grant on some workspace still can't reach
|
|
// it via a bearer-borne caller (CLI / PAT / MCP) —
|
|
// strictly the workspaces they're actually a member of.
|
|
// Falls through to the same not_a_member denial non-admin
|
|
// non-members hit.
|
|
if user.Role == "admin" && isBearer {
|
|
s.recordMCPAuthzDenial(r, "not_a_member")
|
|
writeError(w, http.StatusForbidden, "forbidden", "You are not a member of this workspace")
|
|
return
|
|
}
|
|
// Not a member — check for guest access via grants
|
|
hasGrants, grantErr := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID)
|
|
if grantErr != nil {
|
|
slog.Error("failed to check guest grants", "workspace_id", ws.ID, "user_id", user.ID, "error", grantErr)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check workspace access")
|
|
return
|
|
}
|
|
if !hasGrants {
|
|
// TASK-961: not_a_member denial — counts only when the
|
|
// request originated from the /mcp surface (otherwise
|
|
// the regular /api/v1 403s would inflate the MCP-
|
|
// specific counter).
|
|
s.recordMCPAuthzDenial(r, "not_a_member")
|
|
writeError(w, http.StatusForbidden, "forbidden", "You are not a member of this workspace")
|
|
return
|
|
}
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, "guest")
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, ctxWorkspaceRole, member.Role)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// workspaceRole returns the user's role in the current workspace,
|
|
// as set by RequireWorkspaceAccess. Returns empty string if not set.
|
|
func workspaceRole(r *http.Request) string {
|
|
v, _ := r.Context().Value(ctxWorkspaceRole).(string)
|
|
return v
|
|
}
|
|
|
|
// RecordMCPTierMismatch bumps the pad_mcp_authz_denials_total counter
|
|
// with reason="tier_mismatch" (TASK-1119). Wired from
|
|
// internal/mcp/dispatch_http.go's OnScopeDenied callback — every
|
|
// dispatcher invocation is by construction MCP-origin (only the /mcp
|
|
// surface routes through HTTPHandlerDispatcher), so unlike
|
|
// recordMCPAuthzDenial below this helper does NOT need a context
|
|
// gate.
|
|
//
|
|
// Exported for cross-package wiring (cmd/pad attaches it as the
|
|
// dispatcher observer at startup). The (method, urlPath) arguments
|
|
// match the dispatcher's callback signature so callers can wire with
|
|
// a one-liner; we currently ignore them because the metric only
|
|
// labels by reason.
|
|
//
|
|
// No-op when metrics aren't wired (selfhost / tests).
|
|
func (s *Server) RecordMCPTierMismatch(method, urlPath string) {
|
|
_ = method
|
|
_ = urlPath
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
s.metrics.MCPAuthzDenialsTotal.WithLabelValues("tier_mismatch").Inc()
|
|
}
|
|
|
|
// recordMCPAuthzDenial bumps the pad_mcp_authz_denials_total counter
|
|
// when the request originated from the MCP surface. The discriminator
|
|
// is the presence of an MCPTokenIdentity in the request context —
|
|
// MCPBearerAuth stashes that for every authenticated /mcp request, and
|
|
// no other entry point sets it.
|
|
//
|
|
// Called from middleware that runs DOWNSTREAM of MCPBearerAuth (the
|
|
// in-process MCP dispatcher routes through the API handler tree, so
|
|
// /api/v1/* middleware sees the same context). Called sites must pass
|
|
// a reason string from the documented vocabulary in
|
|
// internal/metrics/metrics.go's MCPAuthzDenialsTotal comment.
|
|
//
|
|
// No-op when metrics aren't wired (selfhost / tests) or when the
|
|
// request didn't come through MCP — keeps the metric MCP-specific
|
|
// without forcing every caller to repeat the gate.
|
|
func (s *Server) recordMCPAuthzDenial(r *http.Request, reason string) {
|
|
if s.metrics == nil {
|
|
return
|
|
}
|
|
if kind, _ := MCPTokenIdentityFromContext(r.Context()); kind == "" {
|
|
return
|
|
}
|
|
s.metrics.MCPAuthzDenialsTotal.WithLabelValues(reason).Inc()
|
|
}
|
|
|
|
// requireRole checks if the user's workspace role meets the minimum
|
|
// required level. Role hierarchy: owner > editor > viewer.
|
|
func requireRole(r *http.Request, minRole string) bool {
|
|
role := workspaceRole(r)
|
|
if role == "" {
|
|
return false
|
|
}
|
|
return roleLevel(role) >= roleLevel(minRole)
|
|
}
|
|
|
|
// requireMinRole checks role and writes a 403 if insufficient.
|
|
// Returns true if the request should continue, false if it was rejected.
|
|
func requireMinRole(w http.ResponseWriter, r *http.Request, minRole string) bool {
|
|
if requireRole(r, minRole) {
|
|
return true
|
|
}
|
|
writeError(w, http.StatusForbidden, "forbidden", "Insufficient permissions")
|
|
return false
|
|
}
|
|
|
|
// roleLevel returns a numeric level for role comparison.
|
|
// Higher values indicate more permissions.
|
|
func roleLevel(role string) int {
|
|
switch role {
|
|
case "owner":
|
|
return 3
|
|
case "editor":
|
|
return 2
|
|
case "viewer":
|
|
return 1
|
|
case "guest":
|
|
return 0 // Guests have grant-based access only, no role-based permissions
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// permissionLevel returns a numeric level for grant permission comparison.
|
|
func permissionLevel(perm string) int {
|
|
switch perm {
|
|
case "owner":
|
|
return 4
|
|
case "admin":
|
|
return 3
|
|
case "editor":
|
|
return 2
|
|
case "edit":
|
|
return 2
|
|
case "viewer":
|
|
return 1
|
|
case "view":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// sha256hex returns the SHA-256 hex digest of a string.
|
|
func sha256hex(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// canonicalIP returns the semantic (canonical text) form of an IP
|
|
// address string. For IPv6 this collapses different valid spellings
|
|
// (compressed vs expanded, uppercase vs lowercase, leading zeroes) to
|
|
// a single representation so comparing two strings by == reflects
|
|
// actual IP equality. For IPv4 it accepts both 4-byte and IPv4-in-IPv6
|
|
// forms. For inputs that don't parse as an IP the original string is
|
|
// returned so behavior stays predictable on malformed/debug values.
|
|
func canonicalIP(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
if ip := net.ParseIP(s); ip != nil {
|
|
return ip.String()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// sessionIPChangeOutcome captures what the caller (TokenAuth / SessionAuth)
|
|
// should do after handleSessionIPChange inspected the session.
|
|
type sessionIPChangeOutcome int
|
|
|
|
const (
|
|
// sessionIPChangeContinue — happy path. IP matches (or no recorded IP).
|
|
// Caller proceeds to install the session user in context and call next.
|
|
sessionIPChangeContinue sessionIPChangeOutcome = iota
|
|
// sessionIPChangeAllowedLogged — IP differs, log-only mode. Session
|
|
// remains valid, caller proceeds as usual.
|
|
sessionIPChangeAllowedLogged
|
|
// sessionIPChangeRevoked — strict mode. The session was destroyed and
|
|
// the caller must NOT install the user in context. For browser (non-API)
|
|
// paths the request should continue unauthenticated so the SPA can
|
|
// render its login screen instead of a raw JSON error.
|
|
sessionIPChangeRevoked
|
|
// sessionIPChangeTerminated — strict mode + API path. A 401 has already
|
|
// been written; caller must return immediately.
|
|
sessionIPChangeTerminated
|
|
)
|
|
|
|
// handleSessionIPChange compares the client IP on this request to the IP
|
|
// recorded when the session was created.
|
|
//
|
|
// Behavior:
|
|
// - Session has no recorded IP (legacy pre-migration row): continue.
|
|
// - IP matches: continue.
|
|
// - Log-only mode (default): atomically rotate the stored IP via
|
|
// compare-and-set. The race winner emits exactly one
|
|
// ActionSessionIPChanged audit row per transition — concurrent
|
|
// requests that lose the CAS skip logging to avoid duplicate rows.
|
|
// - Strict mode (PAD_IP_CHANGE_ENFORCE=strict): do NOT rotate the
|
|
// stored IP. Instead use DeleteSessionIfExists as the CAS primitive
|
|
// — the request that actually deletes the row (a) logs the audit
|
|
// entry once, (b) returns 401 for API / revoked for browser paths.
|
|
// Rotating the IP first would be unsafe: if the subsequent
|
|
// DeleteSession failed (transient DB error) the session would remain
|
|
// valid rebound to the new IP, defeating strict enforcement. By
|
|
// deleting atomically, any failure leaves the session bound to the
|
|
// original IP so a follow-up request from the new IP still mismatches
|
|
// and is still rejected. If the delete errors outright, the request
|
|
// is rejected with 500 so the client can't proceed.
|
|
//
|
|
// Log-only mode breaks fewer legitimate clients (mobile roaming, VPN
|
|
// toggles, carrier NAT) while still giving operators a visible signal.
|
|
func (s *Server) handleSessionIPChange(w http.ResponseWriter, r *http.Request, session *store.SessionInfo, plainToken string) sessionIPChangeOutcome {
|
|
// Canonicalize both sides so equivalent IPv6 representations
|
|
// (compressed vs expanded, case, leading zeros) don't register as a
|
|
// spurious change. Raw trusted-proxy X-Forwarded-For values can
|
|
// arrive in any valid form.
|
|
storedIP := canonicalIP(session.IPAddress)
|
|
newIP := canonicalIP(clientIP(r))
|
|
if storedIP == "" || newIP == "" || storedIP == newIP {
|
|
return sessionIPChangeContinue
|
|
}
|
|
|
|
userID := ""
|
|
if session.User != nil {
|
|
userID = session.User.ID
|
|
}
|
|
|
|
if s.ipChangeEnforceStrict {
|
|
// Destroy the session atomically. Only the caller whose DELETE
|
|
// affected a row logs and issues the "session_ip_changed" response
|
|
// body. A failed delete means the session is still valid AND still
|
|
// bound to the old IP (we skipped the rotation), so a follow-up
|
|
// request from the new IP will hit this path again — safe.
|
|
deleted, err := s.store.DeleteSessionIfExists(plainToken)
|
|
if err != nil {
|
|
// Fail closed: we can't prove the session is gone, so refuse
|
|
// to let the request through. A retry will converge once the
|
|
// DB recovers.
|
|
slog.Error("failed to destroy session on IP-change in strict mode; failing closed",
|
|
"session_ip", storedIP,
|
|
"client_ip", newIP,
|
|
"user_id", userID,
|
|
"error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Unable to validate session. Please try again.")
|
|
return sessionIPChangeTerminated
|
|
}
|
|
if deleted {
|
|
// TASK-3365: this user's other live connections on the destroyed
|
|
// session re-check now.
|
|
s.invalidateUserAccess(userID)
|
|
s.logAuditEventForUser(models.ActionSessionIPChanged, r, userID, auditMeta(map[string]string{
|
|
"old_ip": storedIP,
|
|
"new_ip": newIP,
|
|
}))
|
|
slog.Warn("session destroyed: IP changed (strict enforcement)",
|
|
"session_ip", storedIP,
|
|
"client_ip", newIP,
|
|
"user_id", userID)
|
|
}
|
|
// Clear client-side cookies regardless — even if another request
|
|
// already deleted the row, this client is still holding the now-
|
|
// invalid token.
|
|
clearSessionCookie(w, s.secureCookies)
|
|
clearCSRFCookie(w)
|
|
// Public API paths (login/register/password-reset, health, share
|
|
// links, plan-limits) must STILL work after the session was
|
|
// destroyed — a user with a stale cookie needs a way back in, and
|
|
// Prometheus health probes shouldn't 401 because some other tab
|
|
// left a stale session. Pass the request through unauthenticated.
|
|
if isPublicAPIPath(r.URL.Path) {
|
|
return sessionIPChangeRevoked
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeError(w, http.StatusUnauthorized, "session_ip_changed",
|
|
"Session client IP changed — please log in again.")
|
|
return sessionIPChangeTerminated
|
|
}
|
|
// Non-API (browser) path: caller must NOT install the destroyed
|
|
// session's user into the request context. The SPA will render its
|
|
// unauth state and the user will redirect to login.
|
|
return sessionIPChangeRevoked
|
|
}
|
|
|
|
// Log-only mode: CAS-rotate the stored IP so only the winner logs.
|
|
// The CAS compares against session.IPAddress (the raw value we read
|
|
// from the DB) and writes the canonical newIP so future comparisons
|
|
// are consistent. Err on the side of "winner" when the CAS errors
|
|
// — we'd rather log an extra row than miss the signal entirely.
|
|
// A failed rotate here is non-fatal: worst case the next request
|
|
// also gets an audit row.
|
|
won, err := s.store.UpdateSessionIPIfEquals(plainToken, session.IPAddress, newIP)
|
|
if err != nil {
|
|
slog.Warn("failed to rotate session ip after change", "error", err)
|
|
won = true
|
|
}
|
|
if won {
|
|
s.logAuditEventForUser(models.ActionSessionIPChanged, r, userID, auditMeta(map[string]string{
|
|
"old_ip": storedIP,
|
|
"new_ip": newIP,
|
|
}))
|
|
slog.Info("session client IP changed (logged, request allowed)",
|
|
"session_ip", storedIP,
|
|
"client_ip", newIP,
|
|
"user_id", userID)
|
|
}
|
|
return sessionIPChangeAllowedLogged
|
|
}
|
|
|
|
// handleSessionUAChange compares the User-Agent hash on this request to the
|
|
// hash recorded when the session was created, and enforces the same opt-in
|
|
// policy as handleSessionIPChange (governed by the single
|
|
// PAD_IP_CHANGE_ENFORCE toggle, so operators arm both signals at once).
|
|
//
|
|
// Behavior:
|
|
// - Session has no recorded UA hash (legacy pre-binding row) or the hash
|
|
// matches: continue.
|
|
// - Log-only mode (default): emit exactly the historical slog line and let
|
|
// the request through. Deliberately NO audit row and NO rotation — this
|
|
// preserves today's behavior byte-for-byte for existing self-host users,
|
|
// who would otherwise see new audit noise on routine client churn.
|
|
// - Strict mode (PAD_IP_CHANGE_ENFORCE=strict): destroy the session via
|
|
// DeleteSessionIfExists (the same CAS primitive the IP path uses), log a
|
|
// single ActionSessionUAChanged audit row on the winning delete, and
|
|
// reject the request (401 for API, revoked-passthrough for public/browser
|
|
// paths). A failed delete fails closed with a 500 so a still-valid stolen
|
|
// session can't slip through.
|
|
//
|
|
// Unlike an IP, a real client does not rewrite its own User-Agent mid-session,
|
|
// so a UA mismatch is a stronger theft signal and carries far fewer
|
|
// false-positive lockouts than IP enforcement (which trips on mobile roaming,
|
|
// VPN toggles, and carrier NAT). Both remain opt-in behind the same flag
|
|
// because the UA is still client-supplied — an attacker who stole the token
|
|
// can replay the victim's UA header — so this is a defense-in-depth speed bump,
|
|
// not a hard identity binding.
|
|
//
|
|
// Returns the shared sessionIPChangeOutcome vocabulary so callers can reuse the
|
|
// same switch they use for handleSessionIPChange.
|
|
func (s *Server) handleSessionUAChange(w http.ResponseWriter, r *http.Request, session *store.SessionInfo, plainToken string) sessionIPChangeOutcome {
|
|
if session.UAHash == "" || sha256hex(r.UserAgent()) == session.UAHash {
|
|
return sessionIPChangeContinue
|
|
}
|
|
|
|
userID := ""
|
|
if session.User != nil {
|
|
userID = session.User.ID
|
|
}
|
|
|
|
if !s.ipChangeEnforceStrict {
|
|
// Log-only mode (default): unchanged from the pre-enforce behavior —
|
|
// warn and allow. No audit row so the audit feed is untouched.
|
|
slog.Warn("session binding mismatch: User-Agent changed (logged, request allowed)",
|
|
"session_ip", session.IPAddress,
|
|
"client_ip", clientIP(r))
|
|
return sessionIPChangeContinue
|
|
}
|
|
|
|
// Strict mode: destroy the session atomically. Only the caller whose
|
|
// DELETE affected a row logs + issues the response. A failed delete leaves
|
|
// the session valid, so a follow-up request from the same mismatching UA
|
|
// hits this path again — safe (mirrors handleSessionIPChange).
|
|
deleted, err := s.store.DeleteSessionIfExists(plainToken)
|
|
if err != nil {
|
|
slog.Error("failed to destroy session on UA-change in strict mode; failing closed",
|
|
"session_ip", session.IPAddress,
|
|
"client_ip", clientIP(r),
|
|
"user_id", userID,
|
|
"error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error",
|
|
"Unable to validate session. Please try again.")
|
|
return sessionIPChangeTerminated
|
|
}
|
|
if deleted {
|
|
// TASK-3365: see the IP-change arm.
|
|
s.invalidateUserAccess(userID)
|
|
s.logAuditEventForUser(models.ActionSessionUAChanged, r, userID, auditMeta(map[string]string{
|
|
"session_ip": session.IPAddress,
|
|
"client_ip": clientIP(r),
|
|
}))
|
|
slog.Warn("session destroyed: User-Agent changed (strict enforcement)",
|
|
"session_ip", session.IPAddress,
|
|
"client_ip", clientIP(r),
|
|
"user_id", userID)
|
|
}
|
|
// Clear client-side cookies regardless — even if another request already
|
|
// deleted the row, this client is still holding the now-invalid token.
|
|
clearSessionCookie(w, s.secureCookies)
|
|
clearCSRFCookie(w)
|
|
// Public API paths (login/register/password-reset, health, share links,
|
|
// plan-limits) must still work after revocation so the user can recover.
|
|
if isPublicAPIPath(r.URL.Path) {
|
|
return sessionIPChangeRevoked
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeError(w, http.StatusUnauthorized, "session_ua_changed",
|
|
"Session client changed — please log in again.")
|
|
return sessionIPChangeTerminated
|
|
}
|
|
// Non-API (browser) path: caller must not install the destroyed session's
|
|
// user; the SPA renders its unauth state.
|
|
return sessionIPChangeRevoked
|
|
}
|
|
|
|
// clearSessionCookie expires the session cookie on the client so a
|
|
// subsequent request doesn't keep presenting a now-revoked token.
|
|
// setSessionCookie (re)issues the session cookie with the given value and
|
|
// max-age (seconds). Used by the sliding-renewal path to push the cookie's
|
|
// lifetime forward in lockstep with the extended server-side expiry. The
|
|
// attributes mirror createAuthSession and clearSessionCookie so the cookie's
|
|
// identity (name/path/flags) stays stable across issue, renew, and clear.
|
|
func setSessionCookie(w http.ResponseWriter, value string, maxAge int, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(secure),
|
|
Value: value,
|
|
Path: "/",
|
|
MaxAge: maxAge,
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func clearSessionCookie(w http.ResponseWriter, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName(secure),
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
// tokenAllowedWorkspaceMatches reports whether the OAuth token's
|
|
// workspace allow-list (set at consent time, TASK-952) permits the
|
|
// given workspace slug. The three return-shape cases match
|
|
// TokenAllowedWorkspacesFromContext:
|
|
//
|
|
// - nil — no allow-list set (PAT auth, or pre-TASK-952 tokens) →
|
|
// allow.
|
|
// - ["*"] — wildcard consent → allow.
|
|
// - [slug-a, slug-b, ...] — explicit allow-list → require slug ∈
|
|
// list.
|
|
//
|
|
// Empty (non-nil) slice — the SetAllowedWorkspaces guard rejects
|
|
// nil → empty translation, and the consent flow rejects
|
|
// `allowed_workspaces` with no entries (handlers_oauth.go's
|
|
// parseConsentPayload). So an empty list shouldn't appear in
|
|
// practice; if it does, fail closed (no slug matches an empty list).
|
|
//
|
|
// Used by RequireWorkspaceAccess; package-private because the
|
|
// allow-list semantics are coupled to that middleware's flow.
|
|
func tokenAllowedWorkspaceMatches(ctx context.Context, slug string) bool {
|
|
allowed := TokenAllowedWorkspacesFromContext(ctx)
|
|
if allowed == nil {
|
|
return true // no token-level gate
|
|
}
|
|
for _, entry := range allowed {
|
|
if entry == "*" {
|
|
return true
|
|
}
|
|
if entry == slug {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tokenScopeAllows checks if the token's scopes permit the given HTTP method
|
|
// and path. Scopes are stored as a JSON array of strings.
|
|
//
|
|
// Supported scopes (PAT vocabulary):
|
|
// - "*" — full access
|
|
// - "read" — GET/HEAD/OPTIONS only
|
|
// - "write" — all methods
|
|
//
|
|
// Supported scopes (OAuth 2.1 / RFC 6749 vocabulary, sub-PR E TASK-1027):
|
|
// - "pad:read" — equivalent to PAT "read"
|
|
// - "pad:write" — equivalent to PAT "write"
|
|
// - "pad:admin" — equivalent to PAT "*"
|
|
//
|
|
// MCPBearerAuth's OAuth introspection branch translates fosite's
|
|
// space-separated scope string into the same JSON-array stash form
|
|
// PAT auth uses, so the same policy applies to both transports. This
|
|
// keeps MCP tool authorization centralized: a `pad:read` OAuth token
|
|
// can drive read tools; a `pad:write` OAuth token can drive any.
|
|
//
|
|
// Policy (deny-by-default whitelist):
|
|
// - Empty scope string → allow (legacy DB rows where the column was never
|
|
// populated). These tokens pre-date scope enforcement.
|
|
// - Empty JSON array `[]` → allow (explicit "unrestricted" legacy form,
|
|
// equivalent to no scopes recorded).
|
|
// - Parseable array with at least one scope: allow iff a RECOGNIZED scope
|
|
// grants this method. Unrecognized scopes never contribute to allow
|
|
// and are logged once per request so operators can spot typos like
|
|
// "read-only" that would previously have fallen open.
|
|
// - Unparseable JSON → deny (data corruption or tampering). Previously
|
|
// fell open; that was the security concern behind TASK-667.
|
|
//
|
|
// Switching to deny-by-default closes the hole where a future scope name
|
|
// like "read-only" would silently grant full access.
|
|
func tokenScopeAllows(scopesJSON, method, path string) bool {
|
|
_ = path // reserved for future per-resource scopes
|
|
|
|
// Empty string (legacy DB rows where the column was never populated)
|
|
// → full access. Same fast path for the explicit wildcard.
|
|
if scopesJSON == "" || strings.TrimSpace(scopesJSON) == `["*"]` {
|
|
return true
|
|
}
|
|
|
|
var scopes []string
|
|
if err := json.Unmarshal([]byte(scopesJSON), &scopes); err != nil {
|
|
slog.Warn("token has unparseable scopes; denying request",
|
|
"method", method, "path", path, "error", err)
|
|
return false
|
|
}
|
|
|
|
// Distinguish JSON null (scopes == nil after unmarshal) from an
|
|
// explicit empty array (non-nil, len 0). null falls into the deny
|
|
// path because it signals a client-side serializer bug, not a
|
|
// documented legacy form. An explicit [] (including whitespace-
|
|
// padded "[ ]" or "[\n]") is the documented legacy-unrestricted
|
|
// form and keeps working.
|
|
if scopes == nil {
|
|
slog.Warn("token has null scopes; denying request",
|
|
"method", method, "path", path, "scopes_json", scopesJSON)
|
|
return false
|
|
}
|
|
if len(scopes) == 0 {
|
|
// Explicit empty array → unrestricted (legacy pre-enforcement).
|
|
return true
|
|
}
|
|
|
|
allowed := false
|
|
var unknown []string
|
|
for _, scope := range scopes {
|
|
switch scope {
|
|
case "*", "write", "pad:write", "pad:admin":
|
|
allowed = true
|
|
case "read", "pad:read":
|
|
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions {
|
|
allowed = true
|
|
}
|
|
default:
|
|
unknown = append(unknown, scope)
|
|
}
|
|
}
|
|
|
|
if len(unknown) > 0 {
|
|
slog.Warn("token has unrecognized scopes; treated as no-grant",
|
|
"method", method, "path", path, "unknown_scopes", unknown)
|
|
}
|
|
return allowed
|
|
}
|