Files
pad/internal/server/pinned_token.go
T
xarmian 5035168e5c fix(security)!: a workspace-pinned API token is confined to its workspace (BUG-3982) (#2266)
* wip(security): BUG-3982 pinned-PAT enforcement, in progress

Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd

* fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1)

r1 rulings (lead, on BUG-3982):
- F1: docs/api.md says details.token_workspace is absent for a deleted pin.
- F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's
  tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes
  only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one
  statement); any other id is the missing-id 404.
- Q1: an MCP tool mapped to a global route (pad_workspace create) is
  refused for a pinned PAT, tested through wireMCP.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test(e2e): the suite token is unpinned (BUG-3982 CI)

global-setup minted the browser suite's token with a workspace_id on a
stale premise (api_tokens once required one). Under BUG-3982 a pinned
token is refused the global routes the UI calls on every page, so the
E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and
/auth/export. task-3415's deliberate pin is kept.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3)

GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter,
but the token route gate read "import-status" as a slug and admitted a
pinned token, which could then read the user's import outcome for any
workspace by key. It joins reorder, import and deleted as a refused
segment.

The route walk skipped every route the gate admits, so it could not
catch a route admitted wrongly. Each admitted route must now sit inside
the workspace subrouter or be in a hand-written expected list, and the
list must match what the gate admits.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4)

r4 rulings (lead, on BUG-3982):
- On every door that takes a workspace by name (RequireWorkspaceAccess,
  SSE /events?workspace=, search ?workspace=), a pinned token naming any
  other workspace is refused before the name is resolved
  (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug
  get byte-identical refusals. The resolved-ID compares stay behind it.
- The email-restricted share link and the invitation preview read the
  caller through signedInFor: a token pinned to another workspace counts
  as signed out there.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4)

The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant
rerun showed that mutant surviving. This direct test, shaped like
TestBUG3982_WorkspaceAccessComparesThePinByID, holds it.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5)

r5 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace refuses a pinned token naming any other
  target_workspace before resolving it, so copy and preflight give a
  missing, non-member and member target the same refusal. The OAuth
  allow-list check, reported after the role check, is unchanged.
- The /-/r/ resolver checks the pin by name before resolving; its
  answer was already the same 404.
- Both join the identical-bytes test; the resolver also gets a by-ID
  test, since its resolved-ID compare is now behind the by-name one.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4

* fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6)

r6 rulings (lead, on BUG-3982):
- authorizeCrossWorkspace compares the pin by resolved ID behind its
  by-name check, like RequireWorkspaceAccess, SSE, the resolver and
  collab, with a by-ID test.
- Collab's resolved-ID compare gets the by-ID test the others have.
- docs/api.md: five BUG-3982 sentences appended without a separator
  now end the sentence before them.

Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
2026-10-21 02:43:37 -04:00

167 lines
6.8 KiB
Go

package server
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// tokenPin is the workspace a USER-owned API token was minted for (BUG-3982).
// A token with a pin reaches that workspace and nothing else: not another
// workspace its user belongs to, and not the routes that span workspaces.
//
// ID is always set. Slug is empty when the pinned workspace no longer exists
// (deleted or purged); the token then reaches no workspace at all.
type tokenPin struct {
ID string
Slug string
}
// tokenPinFrom returns the request's token pin, or nil when the credential is
// not a workspace-pinned user token (a cookie or CLI session, an OAuth token,
// an unpinned PAT, a legacy user-less workspace token).
func tokenPinFrom(ctx context.Context) *tokenPin {
p, _ := ctx.Value(ctxTokenPin).(*tokenPin)
return p
}
// withTokenPin confines ctx to the workspace a user-owned token is pinned to.
// It records the pin, and sets the one-slug allow-list [workspace slug] that
// every workspace-global door already honours for OAuth consent scoping
// (search fan-out, GET /workspaces, backlinks, MCP lister and resources, item
// copy), so those doors need no second mechanism. A pin whose workspace is
// gone sets the EMPTY allow-list, which fails closed.
//
// A token with no user (legacy workspace token) or no workspace_id is
// returned unchanged: legacy tokens have their own confinement (BUG-3353).
func (s *Server) withTokenPin(ctx context.Context, t *models.APIToken) (context.Context, error) {
if t == nil || t.UserID == "" || t.WorkspaceID == "" {
return ctx, nil
}
ws, err := s.store.GetWorkspaceByID(t.WorkspaceID)
if err != nil {
return ctx, err
}
if ws == nil {
ctx = context.WithValue(ctx, ctxTokenPin, &tokenPin{ID: t.WorkspaceID})
return WithTokenAllowedWorkspaces(ctx, []string{}), nil
}
ctx = context.WithValue(ctx, ctxTokenPin, &tokenPin{ID: ws.ID, Slug: ws.Slug})
return WithTokenAllowedWorkspaces(ctx, []string{ws.Slug}), nil
}
// pinAdmitsWorkspaceRef reports whether a pinned token may address the
// workspace named ref (a slug or an ID, as a request carries it), decided
// BEFORE ref is resolved (BUG-3982 r4): a slug that does not exist, one the
// user is not a member of, and one the user is a member of all get the same
// refusal, so the pin answers nothing about existence or membership. A pin
// whose workspace is gone admits nothing.
func pinAdmitsWorkspaceRef(pin *tokenPin, ref string) bool {
return pin.Slug != "" && (ref == pin.Slug || ref == pin.ID)
}
// signedInFor is currentUser for a public route that reads the caller about a
// resource in workspaceID (a share link, an invitation). A token pinned to
// another workspace is anonymous there (BUG-3982 r4), so its user's identity
// unlocks nothing and says nothing outside the pin.
func signedInFor(r *http.Request, workspaceID string) *models.User {
if pin := tokenPinFrom(r.Context()); pin != nil && pin.ID != workspaceID {
return nil
}
return currentUser(r)
}
// pinnedTokenExtraRoutes are the non-workspace routes a pinned token may use
// beyond what a legacy workspace token may (BUG-3982): the account doors that
// show who the token is and let it list and revoke the tokens pinned to its
// own workspace (revoking is the incident path; the handlers narrow both to
// that set), the pre-auth flows a stale bearer must not break, and
// the caller's own to-do feed, which already filters to the pinned workspace.
func pinnedTokenExtraRoute(method, path string) bool {
switch path {
case "/api/v1/me/needs",
"/api/v1/auth/session",
"/api/v1/auth/me",
"/api/v1/auth/tokens":
return method == http.MethodGet
case "/api/v1/auth/logout",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/bootstrap",
"/api/v1/auth/forgot-password",
"/api/v1/auth/reset-password",
"/api/v1/auth/local-reset",
"/api/v1/auth/verify-email",
"/api/v1/auth/resend-verification",
"/api/v1/auth/2fa/login-verify",
"/api/v1/auth/cli/sessions":
return method == http.MethodPost
case "/api/v1/auth/check-username":
return method == http.MethodGet
}
if id, ok := strings.CutPrefix(path, "/api/v1/auth/tokens/"); ok {
return method == http.MethodDelete && id != "" && !strings.Contains(id, "/")
}
if code, ok := strings.CutPrefix(path, "/api/v1/auth/cli/sessions/"); ok {
return method == http.MethodGet && code != "" && !strings.Contains(code, "/")
}
return false
}
// pinnedTokenRouteAllowed reports whether a workspace-pinned user token may
// use r's route (BUG-3982). A route is allowed when it is workspace-scoped (so
// RequireWorkspaceAccess confines it to the pin), when a legacy workspace
// token may use it for the same reason (legacyTokenRouteAllowed), when it is
// one of pinnedTokenExtraRoute, or when it is public and not under /auth/.
// Everything else is refused, so a route added later is closed to a pinned
// token until it is listed here.
func pinnedTokenRouteAllowed(r *http.Request) bool {
path := r.URL.Path
if legacyTokenRouteAllowed(r) {
return true
}
if pinnedTokenExtraRoute(r.Method, path) {
return true
}
return isPublicAPIPath(path) && !strings.HasPrefix(path, "/api/v1/auth/")
}
// tokenWorkspaceDenial describes why a token is refused a workspace: the
// message to say and the details to attach. details is nil for a token with no
// pin, which keeps the OAuth allow-list answer it always had.
func tokenWorkspaceDenial(pin *tokenPin) (message string, details map[string]interface{}) {
if pin == nil {
return "Token is not authorized for this workspace", nil
}
const mint = "To use other workspaces, create a token without a workspace: " +
"pad token create --name <name>, or Account settings \u2192 API Tokens."
if pin.Slug == "" {
return "This API token is limited to a workspace that no longer exists. " + mint,
map[string]interface{}{"reason": forbiddenReasonTokenWorkspacePinned}
}
return fmt.Sprintf("This API token is limited to workspace %q. ", pin.Slug) + mint,
map[string]interface{}{"reason": forbiddenReasonTokenWorkspacePinned, "token_workspace": pin.Slug}
}
// writeTokenWorkspaceDeniedFor writes the 403 for a token refused a workspace.
// A pinned token gets the pin reason, naming its workspace and how to mint an
// unscoped token (BUG-3982); any other token keeps the OAuth allow-list
// answer it always had.
func writeTokenWorkspaceDeniedFor(w http.ResponseWriter, pin *tokenPin) {
msg, details := tokenWorkspaceDenial(pin)
if details != nil {
writeError2(w, http.StatusForbidden, "permission_denied", msg, details)
return
}
writeForbidden(w, "permission_denied", msg, forbiddenReasonWorkspaceNotAuthorized)
}
// writeTokenWorkspaceDenied is writeTokenWorkspaceDeniedFor for the request's
// own token.
func writeTokenWorkspaceDenied(w http.ResponseWriter, r *http.Request) {
writeTokenWorkspaceDeniedFor(w, tokenPinFrom(r.Context()))
}