mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +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
425 lines
17 KiB
Go
425 lines
17 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/server"
|
|
)
|
|
|
|
// =====================================================================
|
|
// HTTP error envelope tests (TASK-977)
|
|
//
|
|
// Pin the contract that classifyHTTPStatus + packageHTTPResponse
|
|
// produce closed-set ErrorCode envelopes for every documented HTTP
|
|
// status. The tests double as the test plan from the task spec:
|
|
//
|
|
// | HTTP status | ErrorCode |
|
|
// | 401 | auth_required |
|
|
// | 403 | permission_denied |
|
|
// | 404 (item) | item_not_found |
|
|
// | 404 (ws) | unknown_workspace |
|
|
// | 409 | conflict |
|
|
// | 422 | validation_failed |
|
|
// | 5xx | server_error |
|
|
//
|
|
// Plus the privacy-filter property: unknown_workspace's
|
|
// available_workspaces is filtered by the OAuth token allow-list.
|
|
// =====================================================================
|
|
|
|
// fakeStore implements oauthWorkspaceListerStore for unit tests.
|
|
// One method, no DB; tests can wire deterministic membership lists
|
|
// without spinning up SQLite.
|
|
type fakeStore struct {
|
|
workspaces []WorkspaceHint
|
|
err error
|
|
}
|
|
|
|
func (f *fakeStore) GetUserWorkspaces(_ string) ([]WorkspaceHint, error) {
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
out := make([]WorkspaceHint, len(f.workspaces))
|
|
copy(out, f.workspaces)
|
|
return out, nil
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// 401 / 403 / 404 / 409 / 422 / 5xx envelope round-trips
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
func TestClassifyHTTPStatus_AuthRequiredOn401(t *testing.T) {
|
|
res := classifyHTTPStatus(context.Background(), "item create", 401, []byte(`{"error":{"message":"token expired"}}`), nil)
|
|
if !res.IsError {
|
|
t.Fatal("IsError must be true on 401")
|
|
}
|
|
env, ok := res.StructuredContent.(ErrorEnvelope)
|
|
if !ok {
|
|
t.Fatalf("structured content: got %T, want ErrorEnvelope", res.StructuredContent)
|
|
}
|
|
if env.Error.Code != ErrAuthRequired {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrAuthRequired)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_PermissionDeniedOn403(t *testing.T) {
|
|
res := classifyHTTPStatus(context.Background(), "item update", 403,
|
|
[]byte(`{"error":{"message":"insufficient role"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrPermissionDenied {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrPermissionDenied)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_ItemNotFoundOn404Generic(t *testing.T) {
|
|
// Body doesn't mention "workspace" → defaults to item_not_found.
|
|
res := classifyHTTPStatus(context.Background(), "item show", 404,
|
|
[]byte(`{"error":{"message":"item not found"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrItemNotFound {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrItemNotFound)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_UnknownWorkspaceOn404WorkspaceBody(t *testing.T) {
|
|
res := classifyHTTPStatus(context.Background(), "item list", 404,
|
|
[]byte(`{"error":{"message":"workspace 'foo' not visible"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrUnknownWorkspace)
|
|
}
|
|
// The slug should be extracted from the body.
|
|
if !strings.Contains(env.Error.Message, "foo") {
|
|
t.Errorf("expected slug 'foo' in message; got %q", env.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_ConflictOn409(t *testing.T) {
|
|
res := classifyHTTPStatus(context.Background(), "item update", 409,
|
|
[]byte(`{"error":{"message":"version mismatch"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrConflict {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrConflict)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_ValidationOn422(t *testing.T) {
|
|
res := classifyHTTPStatus(context.Background(), "item create", 422,
|
|
[]byte(`{"error":{"message":"title is required"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrValidationFailed {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrValidationFailed)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_ValidationOn400(t *testing.T) {
|
|
// 400 also maps to validation_failed (the handler's StatusBadRequest
|
|
// path is semantically equivalent for input rejection).
|
|
res := classifyHTTPStatus(context.Background(), "item create", 400,
|
|
[]byte(`{"error":{"message":"invalid json"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrValidationFailed {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrValidationFailed)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_ServerErrorOn500(t *testing.T) {
|
|
// TASK-1078: 5xx now classifies as upstream_error (transient
|
|
// backend failure), distinct from server_error (catch-all for
|
|
// dispatcher internal failures and un-mapped 4xx). The split lets
|
|
// agents tell "retry the upstream" from "fix the request" /
|
|
// "file a bug."
|
|
res := classifyHTTPStatus(context.Background(), "item create", 500,
|
|
[]byte(`{"error":{"message":"db connection failed"}}`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrUpstreamError {
|
|
t.Errorf("code: got %q, want %q", env.Error.Code, ErrUpstreamError)
|
|
}
|
|
}
|
|
|
|
func TestClassifyHTTPStatus_OtherClientStatusFallsToServerError(t *testing.T) {
|
|
// 418 has no taxonomy slot — fall through to server_error rather
|
|
// than silently promote to validation_failed (which would mislead
|
|
// callers that the input was at fault).
|
|
res := classifyHTTPStatus(context.Background(), "item create", 418,
|
|
[]byte(`teapot`), nil)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
if env.Error.Code != ErrServerError {
|
|
t.Errorf("418: got code %q, want %q (other 4xx → server_error)",
|
|
env.Error.Code, ErrServerError)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Privacy filter: available_workspaces must be filtered by the
|
|
// OAuth token's allow-list (TASK-977 core property).
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestUnknownWorkspace_AvailableWorkspaces_FilteredByAllowList is the
|
|
// core privacy invariant: when a token's allow-list is [alpha, beta]
|
|
// but the user is also a member of gamma + delta, the
|
|
// unknown_workspace envelope's available_workspaces hint MUST list
|
|
// only [alpha, beta].
|
|
//
|
|
// Without this filter, an attacker controlling an OAuth client could
|
|
// hit any workspace slug the user is a member of, get the unknown_
|
|
// workspace envelope, and read OFF the user's full workspace list
|
|
// from available_workspaces — defeating the consent UI's "only
|
|
// these workspaces" choice.
|
|
func TestUnknownWorkspace_AvailableWorkspaces_FilteredByAllowList(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{
|
|
{Slug: "alpha", Name: "Alpha"},
|
|
{Slug: "beta", Name: "Beta"},
|
|
{Slug: "gamma", Name: "Gamma"}, // user is a member, NOT in allow-list
|
|
{Slug: "delta", Name: "Delta"}, // same
|
|
},
|
|
}
|
|
lister := &oauthWorkspaceLister{store: store}
|
|
|
|
// User is on context (MCPBearerAuth's WithCurrentUser) AND token
|
|
// allow-list = [alpha, beta] (sub-PR E's WithTokenAllowedWorkspaces).
|
|
ctx := server.WithCurrentUser(context.Background(), fakeUser("user-1"))
|
|
ctx = server.WithTokenAllowedWorkspaces(ctx, []string{"alpha", "beta"})
|
|
|
|
res := classifyHTTPStatus(ctx, "item show", 404,
|
|
[]byte(`{"error":{"message":"workspace 'epsilon' not visible"}}`), lister)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Fatalf("code: got %q, want %q", env.Error.Code, ErrUnknownWorkspace)
|
|
}
|
|
got := slugSet(env.Error.AvailableWorkspaces)
|
|
want := map[string]bool{"alpha": true, "beta": true}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("available_workspaces leaked: got %v, want exactly [alpha beta]",
|
|
env.Error.AvailableWorkspaces)
|
|
}
|
|
for slug := range want {
|
|
if !got[slug] {
|
|
t.Errorf("missing expected slug %q from filtered allow-list", slug)
|
|
}
|
|
}
|
|
for slug := range got {
|
|
if !want[slug] {
|
|
t.Errorf("PRIVACY LEAK: slug %q in available_workspaces but NOT in token allow-list", slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnknownWorkspace_Wildcard_NoFilterApplied verifies that a
|
|
// wildcard allow-list (`["*"]`) returns ALL the user's workspaces
|
|
// in available_workspaces. The user explicitly granted "any" at
|
|
// consent time, so no per-slug filter applies.
|
|
func TestUnknownWorkspace_Wildcard_NoFilterApplied(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{
|
|
{Slug: "alpha"}, {Slug: "beta"}, {Slug: "gamma"},
|
|
},
|
|
}
|
|
lister := &oauthWorkspaceLister{store: store}
|
|
|
|
ctx := server.WithCurrentUser(context.Background(), fakeUser("user-1"))
|
|
ctx = server.WithTokenAllowedWorkspaces(ctx, []string{"*"})
|
|
|
|
res := classifyHTTPStatus(ctx, "item show", 404,
|
|
[]byte(`workspace 'unknown' not visible`), lister)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
got := slugSet(env.Error.AvailableWorkspaces)
|
|
want := map[string]bool{"alpha": true, "beta": true, "gamma": true}
|
|
if len(got) != 3 {
|
|
t.Fatalf("wildcard allow-list: got %d entries, want 3 (all user workspaces); entries=%v",
|
|
len(got), env.Error.AvailableWorkspaces)
|
|
}
|
|
for slug := range want {
|
|
if !got[slug] {
|
|
t.Errorf("missing %q under wildcard allow-list", slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnknownWorkspace_NoTokenAllowList_NoFilterApplied covers the
|
|
// PAT path (or pre-TASK-952 OAuth tokens): no allow-list set →
|
|
// fall back to all the user's workspaces. Behavioural parity with
|
|
// the local CLI's `pad workspace list` output.
|
|
func TestUnknownWorkspace_NoTokenAllowList_NoFilterApplied(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{{Slug: "alpha"}, {Slug: "beta"}},
|
|
}
|
|
lister := &oauthWorkspaceLister{store: store}
|
|
|
|
// No WithTokenAllowedWorkspaces — simulating PAT auth.
|
|
ctx := server.WithCurrentUser(context.Background(), fakeUser("user-1"))
|
|
|
|
res := classifyHTTPStatus(ctx, "item show", 404,
|
|
[]byte(`workspace 'unknown' not visible`), lister)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
if len(env.Error.AvailableWorkspaces) != 2 {
|
|
t.Fatalf("PAT path: got %d entries, want 2 (no token-level filter)",
|
|
len(env.Error.AvailableWorkspaces))
|
|
}
|
|
}
|
|
|
|
// TestUnknownWorkspace_NoUser_EmptyHints covers anonymous probes:
|
|
// without a user on context the lister returns nil and the envelope
|
|
// ships with empty available_workspaces — the rest of the error
|
|
// taxonomy (code + message) is still useful.
|
|
func TestUnknownWorkspace_NoUser_EmptyHints(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{{Slug: "alpha"}},
|
|
}
|
|
lister := &oauthWorkspaceLister{store: store}
|
|
|
|
res := classifyHTTPStatus(context.Background(), "item show", 404,
|
|
[]byte(`workspace 'foo' not visible`), lister)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Errorf("code should still classify; got %q", env.Error.Code)
|
|
}
|
|
if len(env.Error.AvailableWorkspaces) != 0 {
|
|
t.Errorf("anonymous probe must have empty available_workspaces; got %v",
|
|
env.Error.AvailableWorkspaces)
|
|
}
|
|
}
|
|
|
|
// TestUnknownWorkspace_StoreError_EmptyHints covers the "best-effort"
|
|
// contract: if the store lookup fails, the envelope still ships with
|
|
// the right code + message, just with empty available_workspaces.
|
|
func TestUnknownWorkspace_StoreError_EmptyHints(t *testing.T) {
|
|
lister := &oauthWorkspaceLister{store: &fakeStore{err: errors.New("db down")}}
|
|
|
|
ctx := server.WithCurrentUser(context.Background(), fakeUser("user-1"))
|
|
ctx = server.WithTokenAllowedWorkspaces(ctx, []string{"alpha"})
|
|
|
|
res := classifyHTTPStatus(ctx, "item show", 404,
|
|
[]byte(`workspace 'foo' not visible`), lister)
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Errorf("code should still classify even when store fails; got %q", env.Error.Code)
|
|
}
|
|
if len(env.Error.AvailableWorkspaces) != 0 {
|
|
t.Errorf("store failure must yield empty available_workspaces; got %v",
|
|
env.Error.AvailableWorkspaces)
|
|
}
|
|
}
|
|
|
|
// The allow-set builder these listers rely on now lives in internal/server
|
|
// as server.TokenAllowedWorkspaceSet (promoted from mcp's buildAllowSet in
|
|
// BUG-2102 so the workspace-global handlers and these MCP filters share one
|
|
// implementation). Its unit tests moved with it — see
|
|
// internal/server/token_workspace_allowlist_test.go.
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// End-to-end through packageHTTPResponse (the dispatcher's actual
|
|
// call site). Validates that wiring d.Lister into packageHTTPResponse
|
|
// produces a privacy-filtered envelope, exercising the full code
|
|
// path the production /mcp transport runs.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
// TestExecuteRequest_UsesRequestContext_NotOuterContext pins Codex
|
|
// review #379 round 1. The dispatcher's `executeRequest` MUST pass
|
|
// `req.Context()` (not the outer `ctx`) into `packageHTTPResponse`
|
|
// so the lister sees everything `buildHTTPRequest` + `d.Apply`
|
|
// attached: WithCurrentUser, WithAPITokenAuth, and any
|
|
// TokenAllowedWorkspaces the Apply hook layered on.
|
|
//
|
|
// Strategy: drive executeRequest with context.Background() (no
|
|
// values on the outer ctx) + a UserResolver that returns a user
|
|
// + a Handler that 404s with a workspace body. The lister must
|
|
// still see the user via req.Context() and produce
|
|
// available_workspaces. If the buggy version (using outer ctx)
|
|
// runs, the lister sees no user and returns empty hints —
|
|
// asserted against here as the fail mode.
|
|
func TestExecuteRequest_UsesRequestContext_NotOuterContext(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{{Slug: "alpha"}, {Slug: "beta"}},
|
|
}
|
|
user := fakeUser("user-1")
|
|
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(404)
|
|
_, _ = w.Write([]byte(`{"error":{"message":"workspace 'foo' not visible"}}`))
|
|
}),
|
|
// UserResolver runs from the dispatcher's outer context;
|
|
// the user is set on req.Context() inside buildHTTPRequest.
|
|
// The outer ctx (passed to executeRequest) has NO user.
|
|
UserResolver: func(_ context.Context) *models.User { return user },
|
|
Lister: &oauthWorkspaceLister{store: store},
|
|
}
|
|
|
|
res, err := d.executeRequest(context.Background(), "item show", user, "GET", "/api/v1/workspaces/foo/items", nil)
|
|
if err != nil {
|
|
t.Fatalf("executeRequest: %v", err)
|
|
}
|
|
env, ok := res.StructuredContent.(ErrorEnvelope)
|
|
if !ok {
|
|
t.Fatalf("structured content: got %T, want ErrorEnvelope", res.StructuredContent)
|
|
}
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Fatalf("code: got %q, want %q", env.Error.Code, ErrUnknownWorkspace)
|
|
}
|
|
if len(env.Error.AvailableWorkspaces) != 2 {
|
|
t.Errorf("expected 2 hints (user has alpha+beta, no token allow-list); got %d (entries=%v)",
|
|
len(env.Error.AvailableWorkspaces), env.Error.AvailableWorkspaces)
|
|
}
|
|
}
|
|
|
|
func TestPackageHTTPResponse_404Workspace_FiltersAvailableWorkspaces(t *testing.T) {
|
|
store := &fakeStore{
|
|
workspaces: []WorkspaceHint{
|
|
{Slug: "alpha"}, {Slug: "beta"}, {Slug: "gamma"},
|
|
},
|
|
}
|
|
lister := &oauthWorkspaceLister{store: store}
|
|
|
|
ctx := server.WithCurrentUser(context.Background(), fakeUser("user-1"))
|
|
ctx = server.WithTokenAllowedWorkspaces(ctx, []string{"alpha"})
|
|
|
|
resp := &http.Response{
|
|
StatusCode: 404,
|
|
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"workspace 'gamma' not visible"}}`)),
|
|
}
|
|
res, err := packageHTTPResponse(ctx, "item show", resp, lister)
|
|
if err != nil {
|
|
t.Fatalf("packageHTTPResponse: %v", err)
|
|
}
|
|
env := res.StructuredContent.(ErrorEnvelope)
|
|
|
|
if env.Error.Code != ErrUnknownWorkspace {
|
|
t.Fatalf("code: got %q, want %q", env.Error.Code, ErrUnknownWorkspace)
|
|
}
|
|
if len(env.Error.AvailableWorkspaces) != 1 || env.Error.AvailableWorkspaces[0].Slug != "alpha" {
|
|
t.Errorf("end-to-end privacy filter: got %v, want exactly [{Slug:alpha}]",
|
|
env.Error.AvailableWorkspaces)
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// Helpers
|
|
// =====================================================================
|
|
|
|
// fakeUser returns a minimal *models.User the lister's auth check is
|
|
// happy with — only ID is read by the lister itself, everything
|
|
// else stays zero-value.
|
|
func fakeUser(id string) *models.User {
|
|
return &models.User{ID: id}
|
|
}
|
|
|
|
func slugSet(hints []WorkspaceHint) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, h := range hints {
|
|
out[h.Slug] = true
|
|
}
|
|
return out
|
|
}
|