67 lines
16 KiB
Markdown
67 lines
16 KiB
Markdown
---
|
|
phase: "10"
|
|
reviewed: "2026-09-27"
|
|
threats_open: 0
|
|
gate: "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.
|