mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
9f6c1d8f47
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
159 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|