mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
551a338cbd
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
283 lines
11 KiB
Go
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)
|
|
}
|
|
}
|