mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
d895418ea2
Sibling of TASK-1932's CSRFProtect fix: RequireAuth's isCloudAdminPath + hasCloudSecretMarker bypass fired on marker presence, not validated secret. Mirror TASK-1932's currentUser(r) == nil gate exactly. Concretely closes a disabled-admin gap: without the gate, a marker with the wrong secret let RequireAuth's own user.IsDisabled() check be skipped whenever a session was present, reaching handlers that trust a resolved admin session as an alternative to validateCloudSecret. In-handler validation for every cloudAdminPaths handler is unchanged and remains the independent layer for the genuine no-session sidecar case.
1234 lines
50 KiB
Go
1234 lines
50 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"
|
|
// 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"
|
|
)
|
|
|
|
// 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)
|
|
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)
|
|
// 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)
|
|
if apiToken.UserID != "" {
|
|
user, err := s.store.GetUser(apiToken.UserID)
|
|
if err == nil && user != nil {
|
|
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)
|
|
if err != nil || session == nil {
|
|
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)
|
|
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"
|
|
}
|
|
|
|
// 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
|
|
|
|
// 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)
|
|
count, err := s.store.UserCount()
|
|
if 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) != "" {
|
|
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 {
|
|
writeError(w, http.StatusNotFound, "not_found", "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
|
|
count, _ := s.store.UserCount()
|
|
if 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
|
|
}
|
|
writeError(w, http.StatusForbidden, "forbidden", "Token not authorized for this workspace")
|
|
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 {
|
|
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 {
|
|
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
|
|
}
|