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
paritypackage still failsTestMigrateSeedsCanonicalGenresandTestSchemaMatchesPHPSnapshot. Both predate Phase 10, are logged in deferred-items.md, and are the only failures the gate allows.