Files
pad/internal/server/bug3353_legacy_token_test.go
T
xarmian 551a338cbd fix(auth): a user-less legacy workspace token reaches only its own workspace (BUG-3353) (#1734)
A legacy workspace token (an API token with no user) carries one workspace
and nobody's authority, but RequireAuth admitted it on every route. On
global routes it acted with no user:
- POST /workspaces minted a workspace with any slug, attributed to the
  body's owner_id, with no plan limits and no member row. That let it plant
  workspaces on a victim and fill their cloud quota.
- POST /workspaces/import minted unowned workspaces without limit.
- GET /workspaces fell into the fresh-install branch and listed every
  tenant on the instance.

The fix is deny-by-default. RequireAuth admits such a token only on
workspace-scoped routes, where RequireWorkspaceAccess pins it to its own
workspace, and on a short allowlist of global routes that are either
confined to the token's workspace (GET /workspaces, /search, /events,
/collab) or static. Anything else answers 403 legacy_token_not_allowed.
TestBUG3353_EveryGlobalRouteRefusesALegacyToken walks the router, so a
route added later fails closed by test.

- GET /workspaces with such a token lists its own workspace only. The
  no-user bootstrap branch now needs a user count that reads zero.
- Minting: the owner is always the authenticated user and a body owner_id
  is ignored. beginWorkspaceMint refuses a user-less caller unless the
  instance has no users and there is no token. That settles the open
  question BUG-2809's trail left.
- A token pointed at a foreign workspace now answers exactly what a missing
  one does: the workspace 404 on RequireWorkspaceAccess and /events, and
  the item 404 on collab. The 403 told the token which slugs and items
  exist. (Real users' BUG-3069 contract is unchanged, per the lead's
  ruling.)
- TokenAuth never downgrades a user-owned token to a user-less one: a user
  that cannot be read is a 500, one that is gone a 401. Before, the request
  went on with only the token's workspace, which is a legacy token's reach.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 16:06:11 -04:00

283 lines
11 KiB
Go

package server
import (
"context"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3353: a user-less legacy workspace token carries one workspace and no
// one's authority. It is admitted only where its reach is that workspace.
type legacyFixture struct {
srv *Server
slug, wsID string
otherSlug string
otherWSID string
token string
victim *models.User
}
func newLegacyFixture(t *testing.T) legacyFixture {
t.Helper()
srv := testServerWithEvents(t)
slug := createWSWithCollections(t, srv)
other := createWSWithCollections(t, srv)
ws, _ := srv.store.GetWorkspaceBySlug(slug)
ows, _ := srv.store.GetWorkspaceBySlug(other)
// The instance has users, so no fresh-install bypass applies.
victim, err := srv.store.CreateUser(models.UserCreate{Email: "victim-3353@example.com", Name: "Victim", Password: "pw-test-12345"})
if err != nil {
t.Fatal(err)
}
// A legacy token row predates users: user_id NULL. Minted for a user and
// then detached, because the insert path requires one.
tok, err := srv.store.CreateAPIToken(victim.ID, models.APITokenCreate{Name: "legacy", WorkspaceID: ws.ID}, 0, 0)
if err != nil {
t.Fatalf("legacy token: %v", err)
}
if _, err := srv.store.DB().Exec(`UPDATE api_tokens SET user_id = NULL WHERE id = ?`, tok.ID); err != nil {
t.Fatalf("detach legacy token: %v", err)
}
return legacyFixture{srv: srv, slug: slug, wsID: ws.ID, otherSlug: other, otherWSID: ows.ID, token: tok.Token, victim: victim}
}
func (f legacyFixture) do(method, path string, body any) *httptest.ResponseRecorder {
return doRequestWithBearer(f.srv, method, path, f.token, body)
}
func allWorkspacesCount(t *testing.T, srv *Server) int {
t.Helper()
var n int
if err := srv.store.DB().QueryRow(`SELECT COUNT(*) FROM workspaces`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// The bug as filed: a workspace token mints a workspace attributed to anyone,
// with no plan limits. Refused, and nothing is created.
func TestBUG3353_LegacyTokenCannotMintAWorkspace(t *testing.T) {
f := newLegacyFixture(t)
before := allWorkspacesCount(t, f.srv)
for _, c := range []struct{ path string }{{"/api/v1/workspaces"}, {"/api/v1/workspaces/import"}} {
rr := f.do("POST", c.path, map[string]any{"name": "Planted", "slug": "planted", "owner_id": f.victim.ID})
if rr.Code != http.StatusForbidden || errorCode(t, rr) != "legacy_token_not_allowed" {
t.Errorf("legacy token POST %s: %d %s, want 403 legacy_token_not_allowed", c.path, rr.Code, rr.Body.String())
}
}
if after := allWorkspacesCount(t, f.srv); after != before {
t.Fatalf("workspaces %d → %d: a legacy token minted one", before, after)
}
}
// The mint door's own answer, without RequireAuth in front of it: a caller
// with no user mints only on a fresh install with no token.
func TestBUG3353_MintDoorRefusesAUserlessCallerOnAnInitializedInstance(t *testing.T) {
f := newLegacyFixture(t)
req := newRequestWithTokenWorkspace("POST", "/api/v1/workspaces", f.wsID)
rr := httptest.NewRecorder()
if _, ok := f.srv.beginWorkspaceMint(rr, req); ok || rr.Code != http.StatusForbidden {
t.Fatalf("beginWorkspaceMint with a legacy token: ok=%v %d", ok, rr.Code)
}
}
// A signed-in caller's body owner_id is ignored: the owner is the caller.
func TestBUG3353_BodyOwnerIDIsIgnored(t *testing.T) {
f := newLegacyFixture(t)
creator, sess := loginTestUser(t, f.srv)
rr := doRequestWithBearer(f.srv, "POST", "/api/v1/workspaces", sess, map[string]any{"name": "Mine", "owner_id": f.victim.ID})
if rr.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
if ws.OwnerID != creator.ID {
t.Fatalf("owner = %s, want the creator %s (the body named %s)", ws.OwnerID, creator.ID, f.victim.ID)
}
}
// GET /workspaces with a legacy token lists its own workspace, not every
// tenant.
func TestBUG3353_LegacyTokenListsOnlyItsWorkspace(t *testing.T) {
f := newLegacyFixture(t)
rr := f.do("GET", "/api/v1/workspaces", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
}
var got []models.Workspace
parseJSON(t, rr, &got)
if len(got) != 1 || got[0].ID != f.wsID {
t.Fatalf("legacy token listed %d workspaces (%+v), want only its own", len(got), got)
}
}
// A foreign workspace answers exactly what a missing one does, on the
// workspace routes and the event stream, so the token learns no slugs.
func TestBUG3353_ForeignWorkspaceIsIndistinguishableFromMissing(t *testing.T) {
f := newLegacyFixture(t)
for _, tmpl := range []string{"/api/v1/workspaces/%s/collections", "/api/v1/events?workspace=%s"} {
foreign := f.do("GET", strings.Replace(tmpl, "%s", f.otherSlug, 1), nil)
missing := f.do("GET", strings.Replace(tmpl, "%s", "no-such-workspace-3353", 1), nil)
if foreign.Code != http.StatusNotFound || foreign.Code != missing.Code || foreign.Body.String() != missing.Body.String() {
t.Errorf("%s: foreign %d %s vs missing %d %s, want the same 404", tmpl, foreign.Code, foreign.Body.String(), missing.Code, missing.Body.String())
}
}
// Its own workspace still works.
if rr := f.do("GET", "/api/v1/workspaces/"+f.slug+"/collections", nil); rr.Code != http.StatusOK {
t.Fatalf("own workspace: %d %s", rr.Code, rr.Body.String())
}
}
// The collab socket answers an item outside the token's workspace as it
// answers a missing item.
func TestBUG3353_ForeignCollabItemIsNotFound(t *testing.T) {
f := newLegacyFixture(t)
item, err := f.srv.store.CreateItem(f.otherWSID, mustCollectionID(t, f.srv, f.otherWSID, "tasks"), models.ItemCreate{Title: "Theirs", Fields: `{"status":"open"}`})
if err != nil {
t.Fatal(err)
}
req := newRequestWithTokenWorkspace("GET", "/api/v1/collab/"+item.ID, f.wsID)
if _, err := f.srv.authorizeCollabAccess(req, item); err == nil {
t.Fatal("collab granted a legacy token an item outside its workspace")
} else if se, ok := err.(*statusError); !ok || se.code != http.StatusNotFound {
t.Fatalf("collab on a foreign item: %v, want the item 404", err)
}
}
// A user-owned token whose user cannot be read is never downgraded to a
// user-less one: that would hand it a legacy token's reach for the request.
func TestBUG3353_UserOwnedTokenIsNeverDowngraded(t *testing.T) {
f := newLegacyFixture(t)
owner, _ := loginTestUser(t, f.srv)
if err := f.srv.store.AddWorkspaceMember(f.wsID, owner.ID, "owner"); err != nil {
t.Fatal(err)
}
tok, err := f.srv.store.CreateAPIToken(owner.ID, models.APITokenCreate{Name: "owned", WorkspaceID: f.wsID}, 0, 0)
if err != nil {
t.Fatal(err)
}
// The user is gone (the row points at nobody): 401, not a user-less pass.
pointAtNobody(t, f.srv, tok.ID)
if rr := doRequestWithBearer(f.srv, "GET", "/api/v1/workspaces/"+f.slug+"/collections", tok.Token, nil); rr.Code != http.StatusUnauthorized {
t.Fatalf("a token whose user is gone: %d %s, want 401", rr.Code, rr.Body.String())
}
// The user cannot be read (a fault): 500, not a user-less pass.
if _, err := f.srv.store.DB().Exec(`UPDATE api_tokens SET user_id = ? WHERE id = ?`, owner.ID, tok.ID); err != nil {
t.Fatal(err)
}
breakTable(t, f.srv, "users")
if rr := doRequestWithBearer(f.srv, "GET", "/api/v1/workspaces/"+f.slug+"/collections", tok.Token, nil); rr.Code != http.StatusInternalServerError {
t.Fatalf("a token whose user cannot be read: %d %s, want 500", rr.Code, rr.Body.String())
}
}
// Every global route the router registers refuses a legacy token unless it is
// on the allowlist, so a route added later fails closed by test, not only by
// code. Workspace-scoped routes are pinned to the token's workspace by
// RequireWorkspaceAccess and are covered above.
func TestBUG3353_EveryGlobalRouteRefusesALegacyToken(t *testing.T) {
// Every route is hit once from one address; the API burst is not what
// is under test.
t.Setenv("PAD_DISABLE_RATE_LIMITS", "1")
f := newLegacyFixture(t)
f.srv.ensureRouter()
param := regexp.MustCompile(`\{[^}]*\}`)
checked := 0
err := chi.Walk(f.srv.router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if !strings.HasPrefix(route, "/api/v1/") || isPublicAPIPath(route) {
return nil
}
path := strings.TrimSuffix(param.ReplaceAllString(route, "x"), "/*")
path = strings.ReplaceAll(path, "*", "x")
req := newRequestWithTokenWorkspace(method, path, f.wsID)
if legacyTokenRouteAllowed(req) {
return nil
}
checked++
rr := doRequestWithBearer(f.srv, method, path, f.token, nil)
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "legacy_token_not_allowed") {
t.Errorf("%s %s (%s): %d %s, want 403 legacy_token_not_allowed", method, route, path, rr.Code, rr.Body.String())
}
return nil
})
if err != nil {
t.Fatal(err)
}
if checked < 50 {
t.Fatalf("only %d global routes were checked; the walk is not seeing the router", checked)
}
}
// newRequestWithTokenWorkspace is a request as TokenAuth leaves a user-less
// legacy workspace token: a workspace and no user.
func newRequestWithTokenWorkspace(method, path, wsID string) *http.Request {
req := httptest.NewRequest(method, path, nil)
return req.WithContext(context.WithValue(req.Context(), ctxTokenWorkspaceID, wsID))
}
// pointAtNobody makes a token's user_id name no user, as a row left behind
// by a user removed without the cascade would. Foreign keys are off for that
// one statement on one connection.
func pointAtNobody(t *testing.T, srv *Server, tokenID string) {
t.Helper()
ctx := context.Background()
conn, err := srv.store.DB().Conn(ctx)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
for _, q := range []string{`PRAGMA foreign_keys = OFF`, `UPDATE api_tokens SET user_id = 'ghost-3353' WHERE id = '` + tokenID + `'`, `PRAGMA foreign_keys = ON`} {
if _, err := conn.ExecContext(ctx, q); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
}
// The global routes a legacy token must be refused on, named independently
// of legacyTokenRouteAllowed: the walk above consults that function, so a
// route it wrongly allows would be skipped there, not caught (codex review).
func TestBUG3353_NamedGlobalRoutesRefuseALegacyToken(t *testing.T) {
t.Setenv("PAD_DISABLE_RATE_LIMITS", "1")
f := newLegacyFixture(t)
for _, c := range []struct{ method, path string }{
{"POST", "/api/v1/workspaces"},
{"POST", "/api/v1/workspaces/import"},
{"GET", "/api/v1/workspaces/deleted"},
{"PUT", "/api/v1/workspaces/reorder"},
{"POST", "/api/v1/workspaces/" + f.otherSlug + "/restore"},
{"POST", "/api/v1/import/url"},
{"GET", "/api/v1/admin/users"},
{"GET", "/api/v1/me/workspace-tabs"},
{"GET", "/api/v1/oauth/clients/x/public-info"},
} {
rr := f.do(c.method, c.path, nil)
if rr.Code != http.StatusForbidden || errorCode(t, rr) != "legacy_token_not_allowed" {
t.Errorf("legacy token %s %s: %d %s, want 403 legacy_token_not_allowed", c.method, c.path, rr.Code, rr.Body.String())
}
}
}
// The setup-mode mint (no users, no token) still works, and a body owner_id
// is dropped there too: the owner is always the authenticated user, and
// there is none.
func TestBUG3353_SetupMintIgnoresABodyOwnerID(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "POST", "/api/v1/workspaces", map[string]any{"name": "First", "owner_id": "someone-3353"})
if rr.Code != http.StatusCreated {
t.Fatalf("setup-mode create: %d %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
if ws.OwnerID != "" {
t.Fatalf("setup-mode create kept the body owner_id %q", ws.OwnerID)
}
}