Files
summercms/.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md

16 KiB

phase, reviewed, threats_open, gate
phase reviewed threats_open gate
10 2026-09-27 0 scripts/check-phase10.sh --all

Phase 10 Security Review

This is a fresh code-and-test review of every threat in the registers of Plans 10-01 to 10-05. A high threat counts as mitigated only when its named test fails with the protection removed. That was checked by mutating the production code and re-running the test, as recorded under "Removal check" below. Accepted and transferred threats keep their rationale from the originating plan.

Commands run from summercms.go. ../fonoteka.go tests run inside that repository. Gate stages are modes of scripts/check-phase10.sh.

Threat Category Component Severity Disposition Production mitigation Test or gate stage Observed result Residual risk
T-10-01 Information Disclosure cabana login/refresh cookie transport high mitigated cabana/auth.go: an X-Requested-With login or cookie refresh writes the JWT only into the HttpOnly summer_admin cookie and returns cookieLoginData (token_type, expires_in). admin/src/api/client.ts never reads a token TestPhase10CookieAuth (cabana), TestPhase10TracerSPA (fonoteka), TestPhase10Coverage/cookie refresh…; stages --security, --postgres pass; removal check fails both tests A script injected into the admin origin could still act with the session. The CSP script-src 'self' and the no-raw-HTML rule (T-10-16) limit that
T-10-02 Tampering cookie-authenticated unsafe admin routes (CSRF) high mitigated cabana/csrf.go requireAjax wraps every POST/PUT/DELETE except login and refuses a request with neither Bearer nor X-Requested-With: XMLHttpRequest before decoding. The cookie is SameSite=Strict TestPhase10CSRF (walks every mounted handler with a body-read spy), TestPhase10Coverage/every unsafe mounted route is CSRF-walked (fails when an unsafe route is added without the walk), TestPhase10TracerSPA step 8; stage --security pass; removal check fails TestPhase10CSRF and TestPhase10TracerSPA Relies on browsers not sending custom headers cross-origin without a CORS preflight, and the admin API answers none
T-10-03 Information Disclosure boardwalk static serving medium mitigated boardwalk/boardwalk.go serves the embedded fs only, with path.Clean. It lists no directory, answers 404 for an extension miss and the JSON envelope for api/ misses go test ./boardwalk (TestTraversalIsCleaned, TestDirectoryIsNeverListed, TestMissingFileWithExtensionIs404, TestPhase10BoardwalkServing encoded traversal); stage --security pass None known
T-10-04 Tampering admin HTML responses (clickjacking, sniffing, indexing) medium mitigated setSecurityHeaders: X-Frame-Options DENY, CSP frame-ancestors none, script-src self, nosniff, Referrer-Policy same-origin, X-Robots-Tag noindex. The index has no inline script TestSecurityHeadersOnEveryResponse, TestNoInlineScript, TestPhase10BoardwalkServing/HEAD…; stage --security pass None known
T-10-05 Spoofing admin session cookie attributes and logout high mitigated cabana/auth.go sessionCookie: HttpOnly, Secure (opt-out refused in production), SameSite=Strict, Path=prefix, Max-Age=refresh TTL. logout blacklists the jti and expires the cookie. refresh loads the admin through the guard's provider and refuses a missing, deactivated or pre-cutoff subject via bouncer.RefreshAudienceFor, expiring the cookie on that refusal TestPhase10CookieAuth, TestPhase10Coverage/cookie refresh… (attributes on refresh), TestPhase10AdminAuth (fonoteka), TestPhase10AssembledAcceptance (old cookie is 401 after logout), TestPhase10Prefix (cookie_secure false refused in production), TestAdminRefreshRevocation (cabana, Postgres), TestRefreshAudienceForSubject (bouncer); stages --security, --postgres pass; removal checks (HttpOnly off, SameSite Lax, blacklist skipped) each fail A stolen cookie or Bearer token stays usable until logout, summer admin:reset-password (tokens_valid_after), deactivation or deletion of the admin, or the end of its refresh window. Before quick task 260927-q23 (CR-01), refresh skipped the tokens_valid_after and is_activated checks, so a reset did not end a session the SPA kept refreshing. The refresh window slides on every refresh, so a session refreshed at least once per refresh_ttl has no absolute expiry (WR-07, open)
T-10-06 Elevation of Privilege prefix and controller ID collisions medium mitigated cabana.AdminPrefix validation, checkReservedSegments, and surf.checkAdminPrefix rejecting non-cabana routes at or under the prefix TestPhase10Prefix, TestPhase10AdminPrefixCollision (exact, deeper, raw, default /backend, sibling allowed); stage --security pass None known
T-10-07 Denial of Service concurrent cookie refresh from two tabs low mitigated fonoteka blacklist_grace: 30. client.ts single-flights one refresh and replays once tests/app/client.test.ts (single-flight, one replay), tests/smoke/tracer.smoke.test.ts; stage --spa pass Two tabs refreshing more than 30 s apart with the same old cookie: the second tab re-logs in
T-10-08 Tampering committed dist and generated types drift from source medium mitigated scripts/check-admin-dist.sh rebuilds from the lockfile. scripts/check-admin-openapi.sh --check regenerates the document and types. Tailwind skips admin/tests and the generated files stages --dist, --openapi pass None known
T-10-09 Elevation of Privilege cabana relation save (IDOR via relation ids) high mitigated cabana/relation_field.go checkRelationScope revalidates every submitted id through scopedRelationQuery (the same RelationExtendOptionsQuery scope) inside the save transaction. It answers 422 and rolls back TestPhase10RelationForgedID (cabana), TestPhase10AlbumRelations (fonoteka forged artist); stages --security, --postgres pass; removal check fails both None known
T-10-10 Tampering belongs-to mapping of protected foreign keys (mass assignment) high mitigated A belongsTo on a protected fill key is ReadOnly. parseRelationValues skips it and assignBelongsTo never writes it, and its options endpoint is 404 TestPhase10CollectionOwnerReadOnly (fonoteka), TestPhase10Coverage/read-only relation label…; stages --security, --postgres pass. Removal check: dropping only the parse-time skip fails TestPhase10Coverage (422 on the read-only key). Dropping both layers fails TestPhase10CollectionOwnerReadOnly (owner_id overwritten) Two independent layers. Removing one alone is still caught by the cabana coverage test
T-10-11 Information Disclosure fields/{field}/options enumeration medium mitigated protect() before SQL, the hook scope, per_page capped at 100, and 404 for non-relation and read-only fields TestPhase10RelationOptions, TestPhase10Coverage/relation option edges; stages --postgres, --go pass None known
T-10-12 Information Disclosure public /lang bundle low mitigated cabana/lang.go serves only keys under backend::lang. TestPhase10Bundle, TestPhase10Coverage/bundle falls back…; stage --security pass Framework UI strings are public by design
T-10-13 Tampering messages and toolbar YAML low mitigated Strict decoding with unknown-key rejection, a custom toolbar unmarshal and boot-time key checks TestPhase10Messages, TestPhase10Toolbar, TestPhase10Coverage/relation messages default…; stage --go pass None known
T-10-14 Tampering OpenAPI document versus handler output medium mitigated Handlers write the documented types. The converter emits exact unions TestPhase10OpenAPIConformance (every route, unknown fields disallowed), TestUnionRewrite, check-admin-openapi.sh --check; stage --openapi pass None known
T-10-15 Elevation of Privilege filters/{scope}/options medium mitigated protect() before the provider. Scope names are allow-listed against the compiled filters; anything else is 404 TestPhase10FilterOptions, TestPhase10Coverage/filter option edges; stage --go pass None known
T-10-16 Tampering SPA rendering of plugin labels, messages and record values (XSS) high mitigated Text interpolation only. admin/src has no v-html, innerHTML or insertAdjacentHTML. interpolate works on plain strings --hygiene (raw-HTML rule, proven by --self-test plant), tests/list/CellValue.test.ts, tests/ui/ui.test.ts (toast and confirm text), tests/form/fields.test.ts (hostile type name); stages --hygiene, --spa pass; removal check (CellValue via v-html) fails the CellValue suite and --hygiene Vue's own escaping is trusted
T-10-17 Tampering login redirect parameter (open redirect) medium mitigated safeRedirect accepts only a path starting with exactly one slash (not // or /\) tests/app/router.test.ts, tests/views/LoginView.test.ts; stage --spa pass None known
T-10-18 Elevation of Privilege client-side hiding of actions and fields low accepted The server enforces permissions, toolbar actions, writable fields and relation scope (Plans 10-01/10-02). The SPA renders only what it receives and never adds entries, so client manipulation gains nothing Server enforcement evidence: TestPhase10AssembledAcceptance (limited admin gets 403 on Albums), TestPhase09PermissionMatrix; stage --postgres pass Accepted: UI hiding is cosmetic
T-10-19 Information Disclosure list state (search terms, filters) in the URL low accepted Admin-only, same-origin, Referrer-Policy same-origin and noindex from Plan 10-01. Search terms are not secrets TestSecurityHeadersOnEveryResponse; stage --security pass Accepted: terms stay in browser history
T-10-20 Tampering Winter redirect and recordUrl strings used for navigation low mitigated mapWinterUrl produces only the current controller's list, create and record routes. Anything else falls back to the list tests/app/winterUrl.test.ts (foreign, absolute, protocol-relative, javascript: inputs); stage --spa pass None known
T-10-21 Elevation of Privilege relation link of candidates outside scope (owner, inactive users) medium transferred Enforced server-side by Phase 9: RelationExtendManageQuery, ExcludedRelatedIDs, and TestCollectionsAdminForgedPivot/CrossScope. The SPA only posts ids chosen from the server's candidates and runs those suites as a regression in Task 1 TestCollectionsAdmin* (fonoteka), TestPhase10AssembledAcceptance (owner not offered); stages --go, --postgres pass Transferred to the Phase 9 server contract
T-10-22 Information Disclosure localStorage low mitigated Only useSidebar.ts writes browser storage: the summer-admin.sidebar boolean --hygiene storage rule (proven by the --self-test plant), tests/state/useSidebar.test.ts (only that key is ever written) pass None known
T-10-23 Spoofing logout on a shared browser medium mitigated useAuth.logout POSTs /auth/logout (the server blacklists and expires the cookie), then clears user, navigation and settings and routes to login, even on a failure tests/state/useAuth.test.ts (success, 500, network failure), tests/shell/UserMenu.test.ts, TestPhase10AssembledAcceptance (old cookie is 401 after logout); stages --spa, --postgres pass None known
T-10-24 Repudiation Phase 10 acceptance evidence high mitigated scripts/check-phase10.sh: phase10_detect refuses failed, skipped, zero-test, non-JSON and build-failed runs, and named tests that did not pass. Only the two documented parity failures are allow-listed, and they refuse once they pass again. OpenAPI and dist drift, hygiene and evidence stages scripts/check-phase10.sh --self-test (synthetic fail, skip, zero, non-JSON, build, package, required, allow-list cases; hygiene plants); stage --all pass; every synthetic bad run is refused with its own exit code The allow-list names two tests owned by a Phase 9 follow-up (deferred-items.md)
T-10-25 Tampering framework/app boundary and hand-maintained API types low mitigated The --hygiene stage refuses: app or Polish catalogue names in summercms.go admin, boardwalk, cabana and phrasebook; types.ts shapes that are not aliases onto the generated schema; direct fetch; raw HTML; foreign origins in dist; icon namespace imports; retired-prefix routes; untested SPA modules --hygiene, --self-test pass None known
T-10-SC Tampering npm/Go dependencies high mitigated No package was added in Plans 10-02 to 10-05. admin/ installs with npm ci against the lockfile approved at the 10-01 blocking-human gate (17 exact pins). swag stays pinned at v1.16.6 via go run scripts/check-phase10.sh --spa (runs npm ci against the lockfile); removal check: a changed pin in a scratch copy makes npm ci exit 1 pass npm 12 blocks the esbuild and vue-demi postinstall scripts. Neither is needed

Removal check

The production code was changed, the named tests were run, and the file was restored (git status clean afterwards). A mitigation counts only if its test fails.

Threat Mutation Command Result
T-10-01 Cookie login body also carries access_token go test ./cabana -run '^TestPhase10CookieAuth$'; fonoteka -run '^TestPhase10TracerSPA$' both exit 1
T-10-02 requireAjax lets every request through go test ./cabana -run '^TestPhase10CSRF$'; fonoteka -run '^TestPhase10TracerSPA$' both exit 1
T-10-05 HttpOnly: false go test ./cabana -run '^TestPhase10Coverage$'; fonoteka -run '^TestPhase10AdminAuth$' both exit 1
T-10-05 SameSite: Lax same two tests both exit 1
T-10-05 logout skips the blacklist go test ./cabana -run '^TestPhase10CookieAuth$'; fonoteka -run '^TestPhase10AssembledAcceptance$' both exit 1
T-10-09 checkRelationScope never returns the 422 go test ./cabana -run '^TestPhase10RelationForgedID$'; fonoteka -run '^TestPhase10AlbumRelations$' both exit 1
T-10-10 parse no longer skips read-only relations go test ./cabana -run '^TestPhase10Coverage$' exit 1
T-10-10 parse skip and write skip both removed fonoteka -run '^TestPhase10CollectionOwnerReadOnly$' exit 1 (owner_id overwritten)
T-10-16 CellValue renders text through v-html npx vitest run tests/list/CellValue.test.ts; scripts/check-phase10.sh --hygiene both exit 1
T-10-24 synthetic fail, skip, zero, non-JSON, build, package, missing required and stale allow-list runs scripts/check-phase10.sh --self-test each refused with its exit code
T-10-SC vue pin changed in a scratch package.json npm ci --dry-run --offline against the committed lockfile exit 1

Only one single-layer mutation left its named test green. Removing just the parse-time skip for T-10-10 did not fail TestPhase10CollectionOwnerReadOnly, because assignBelongsTo independently refuses protected keys. The review therefore names TestPhase10Coverage, which catches that layer, as well as the fonoteka test, which catches the full removal.

Residual risk

  • Visual fidelity and the full browser flow are manual checks, not security controls (10-VALIDATION.md, manual-only rows).
  • The fonoteka.go parity package still fails TestMigrateSeedsCanonicalGenres and TestSchemaMatchesPHPSnapshot. Both predate Phase 10, are logged in deferred-items.md, and are the only failures the gate allows.