Files
pad/internal/server/token_workspace_allowlist_test.go
T
xarmian 9f6c1d8f47 fix(security): scope MCP workspace-global reads by OAuth consent allow-list (BUG-2102) (#935)
The OAuth token consent allow-list (TokenAllowedWorkspaces) was enforced only
by RequireWorkspaceAccess, which fires solely for /{slug} path-param routes.
Every MCP-reachable read that is workspace-global or takes the workspace as a
query/body param bypassed the gate, so a token consented to workspace A could
reach data in other co-membership workspaces. Investigation found five
bypasses; this closes all of them:

- pad_search (HIGH): fan-out (no workspace) searched ALL memberships; naming a
  workspace returned its item titles + content. Now the fan-out is restricted
  to the allow-list and a named non-consented workspace returns empty (no
  existence leak).
- pad_workspace.list (the original BUG-2102): filtered by the allow-list.
- pad_workspace.deleted: filtered by the allow-list.
- pad_workspace.audit-log: platform-wide admin surface; denied for
  consent-scoped tokens.
- pad_workspace.restore: gated by the allow-list (404 for out-of-consent slugs).

All gates are no-ops for nil/wildcard allow-lists, so PAT auth, web sessions,
and local stdio are unchanged.

The allow-set semantics move into internal/server as the canonical
TokenAllowedWorkspaceSet(ctx) (promoted from internal/mcp's buildAllowSet);
the two mcp call sites (error-hint lister, workspaces resource) and its unit
tests migrate with it, so server handlers and MCP filters share one
implementation instead of drifting per-surface (the pattern that caused this
bug: TASK-977 and TASK-2101 each point-fixed one surface).

Tests: per-handler regression tests carrying the WithTokenAllowedWorkspaces
context MCPBearerAuth produces; each asserts the consent layer (not membership)
drives exclusion, with nil/wildcard baselines guarding against over-blocking.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 20:37:03 -04:00

159 lines
5.8 KiB
Go

package server
import (
"context"
"testing"
)
// TestTokenAllowedWorkspaceMatches covers the policy table for the
// OAuth-token workspace allow-list (TASK-953):
//
// - nil allow-list → no gate; every slug allowed (PAT auth or
// pre-TASK-952 OAuth tokens fall here).
// - ["*"] wildcard → every slug allowed.
// - ["foo", "bar"] → only "foo" and "bar" allowed; "baz" denied.
// - explicit empty list → denies every slug (fail-closed; in
// practice the consent flow rejects empty lists at parse time,
// but the helper must not "open up" if one slips through).
// - ["foo", "*"] → wildcard wins; every slug allowed.
//
// Cheap pure-function unit test; the integration flow is covered
// separately in handlers_mcp_test.go's MCP+OAuth test surface.
func TestTokenAllowedWorkspaceMatches(t *testing.T) {
cases := []struct {
name string
allowed []string
slug string
want bool
}{
// nil → no gate (PAT auth or pre-TASK-952 token).
{"nil allows anything 1", nil, "foo", true},
{"nil allows anything 2", nil, "anything", true},
// Wildcard.
{"wildcard allows foo", []string{"*"}, "foo", true},
{"wildcard allows arbitrary", []string{"*"}, "anything-else", true},
// Explicit allow-list — only listed slugs match.
{"explicit foo matches foo", []string{"foo"}, "foo", true},
{"explicit foo,bar matches foo", []string{"foo", "bar"}, "foo", true},
{"explicit foo,bar matches bar", []string{"foo", "bar"}, "bar", true},
{"explicit foo,bar denies baz", []string{"foo", "bar"}, "baz", false},
{"explicit foo denies bar", []string{"foo"}, "bar", false},
// Defensive: empty (non-nil) list → fail-closed, no slug
// matches. Consent flow rejects empty allow-lists at parse
// time, so this case shouldn't occur in production.
{"empty list denies anything", []string{}, "foo", false},
// Mixed wildcard + specific entries — wildcard wins. A
// tampered token (or future feature) could have both; the
// safer interpretation is "any" because that's what the
// wildcard explicitly grants.
{"wildcard + specific allows arbitrary", []string{"foo", "*"}, "anything", true},
{"wildcard first allows arbitrary", []string{"*", "foo"}, "anything", true},
// Edge cases.
{"empty slug never matches explicit list", []string{"foo"}, "", false},
{"empty slug matches wildcard", []string{"*"}, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
if tc.allowed != nil {
ctx = WithTokenAllowedWorkspaces(ctx, tc.allowed)
}
got := tokenAllowedWorkspaceMatches(ctx, tc.slug)
if got != tc.want {
t.Errorf("tokenAllowedWorkspaceMatches(%v, %q) = %v, want %v",
tc.allowed, tc.slug, got, tc.want)
}
})
}
}
// TestWithTokenAllowedWorkspaces_DefensiveCopy verifies the helper
// copies the input slice so a caller mutating after the call doesn't
// corrupt the per-request token state. This is the canonical
// defense-in-depth pattern for context values that callers might
// reuse across requests.
func TestWithTokenAllowedWorkspaces_DefensiveCopy(t *testing.T) {
original := []string{"foo", "bar"}
ctx := WithTokenAllowedWorkspaces(context.Background(), original)
// Mutate the original after stashing.
original[0] = "MUTATED"
got := TokenAllowedWorkspacesFromContext(ctx)
if len(got) != 2 || got[0] != "foo" || got[1] != "bar" {
t.Errorf("context value should be insulated from caller mutation; got %v", got)
}
}
// TestTokenAllowedWorkspacesFromContext_ReturnsCopy verifies the
// reader returns a fresh slice — a caller mutating the returned
// value must not corrupt the context-stored allow-list either.
func TestTokenAllowedWorkspacesFromContext_ReturnsCopy(t *testing.T) {
ctx := WithTokenAllowedWorkspaces(context.Background(), []string{"foo", "bar"})
got := TokenAllowedWorkspacesFromContext(ctx)
got[0] = "MUTATED"
// Re-read; the stored value must be unchanged.
got2 := TokenAllowedWorkspacesFromContext(ctx)
if got2[0] != "foo" {
t.Errorf("returned slice should be a copy; got2[0] = %q after caller mutation", got2[0])
}
}
// TestTokenAllowedWorkspaceSet covers the multi-slug companion to
// tokenAllowedWorkspaceMatches, promoted from internal/mcp's buildAllowSet in
// BUG-2102. Same three-shape contract, but returns a slug-set for filtering a
// list rather than gating one slug: nil/wildcard → nil (no filter); explicit
// list → a set; empty (non-nil) list → empty set (fail closed).
func TestTokenAllowedWorkspaceSet(t *testing.T) {
set := func(slugs []string) map[string]struct{} {
ctx := context.Background()
if slugs != nil {
ctx = WithTokenAllowedWorkspaces(ctx, slugs)
}
return TokenAllowedWorkspaceSet(ctx)
}
if got := set(nil); got != nil {
t.Errorf("nil allow-list should yield nil (no filter); got %v", got)
}
if got := set([]string{"*"}); got != nil {
t.Errorf("wildcard should yield nil (no filter); got %v", got)
}
// Defense-in-depth: wildcard mixed with a specific slug still collapses to
// "no filter" — the safer read, matching tokenAllowedWorkspaceMatches.
if got := set([]string{"alpha", "*"}); got != nil {
t.Errorf("wildcard + specific should yield nil (no filter); got %v", got)
}
got := set([]string{"alpha", "beta"})
if got == nil {
t.Fatal("specific list should yield a non-nil set")
}
if _, ok := got["alpha"]; !ok {
t.Error("expected alpha in set")
}
if _, ok := got["beta"]; !ok {
t.Error("expected beta in set")
}
if _, ok := got["gamma"]; ok {
t.Error("gamma must not be in set")
}
// Empty (non-nil) allow-list fails closed: an empty set drops everything.
empty := TokenAllowedWorkspaceSet(WithTokenAllowedWorkspaces(context.Background(), []string{}))
if empty == nil {
t.Fatal("empty (non-nil) allow-list should yield an empty non-nil set (fail closed), not nil")
}
if len(empty) != 0 {
t.Errorf("empty allow-list set should have no entries; got %v", empty)
}
}