- 10.1-SECURITY-REVIEW.md: T-10.1-01 to T-10.1-22 and T-10.1-SC with mitigation, test or gate stage, observed result and 23 removal checks - 10.1-VALIDATION.md: every plan task mapped to its command, all green under check-phase10.1.sh --all; nyquist_compliant and wave 0 complete - Phase 10 deferred item for the parity failures marked resolved
22 KiB
phase, reviewed, threats_open, gate
| phase | reviewed | threats_open | gate |
|---|---|---|---|
| 10.1 | 2026-09-29 | 0 | scripts/check-phase10.1.sh --all |
Phase 10.1 Security Review
This is a fresh code-and-test review of every threat in the registers of Plans 10.1-01 to 10.1-04 (T-10.1-01 to T-10.1-22 and T-10.1-SC). A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. Each one was checked by changing the production code (or the gate script), running the named test, and restoring the file byte for byte, as recorded under "Removal checks" below. The accepted threat keeps its rationale verbatim from its originating plan.
Commands run from summercms.go. ../fonoteka.go tests run inside that repository. Gate stages are modes of scripts/check-phase10.1.sh. Cabana tests are in modules/cabana (phase101_schema_test.go, phase101_render_test.go, phase101_assets_test.go, phase101_actions_test.go). SPA suites are under admin/tests.
| Threat | Category | Component | Severity | Disposition | Production mitigation | Test or gate stage | Observed result | Residual risk |
|---|---|---|---|---|---|---|---|---|
| T-10.1-01 | Information Disclosure | cabana plugin asset route | high | mitigate | cabana/extension.go compileClientAssets reads only declared paths that pass checkAssetPath (clean, under assets/, .js/.mjs or .css, embedded) and keys them vendor/plugin/<path after assets/>. cabana/plugin_assets.go pluginAsset serves by exact key; a miss goes to the SPA handler. The AdminFS is never served wholesale |
TestPhase101Assets (undeclared YAML, _stats.htm, encoded traversal, other plugin all fall through; every boot path rule), TestPhase10OpenAPIConformance (assertPluginAsset), TestPhase101AlbumsExtension/declared_assets… (fonoteka); stages --security, --postgres |
pass; removal checks RC-01 and RC-02 fail TestPhase101Assets | A plugin can still declare and publish any file it puts under assets/; that is its own choice, like its Go code |
| T-10.1-02 | Tampering | plugin asset responses (MIME sniffing) | medium | mitigate | boardwalk.ContentType gives explicit text/javascript; charset=utf-8 and text/css; charset=utf-8. pluginAsset sets boardwalk.SetSecurityHeaders (nosniff, CSP script-src 'self', DENY, same-origin referrer, noindex) and Cross-Origin-Resource-Policy: same-origin |
TestPhase101Assets (hit headers), TestPhase101BoardwalkExports, TestPhase101AlbumsExtension (fonoteka assets); stage --security |
pass | None known |
| T-10.1-03 | Tampering | stale plugin JS after a rebuild | low | mitigate | Schema URLs carry ?v= plus the first 12 hex characters of the file's sha256. Responses are Cache-Control: no-cache with a sha256 ETag, never immutable |
TestPhase101Assets (?v= equals the hash prefix, ETag, 304 on If-None-Match, 200 on a stale ETag, no immutable); stage --security |
pass | None known |
| T-10.1-04 | Tampering | widget and toolbar POST routes (CSRF) | high | mitigate | cabana/http.go mounts both routes through requireAjax; a cookie request without X-Requested-With is refused before the body is read |
TestPhase10CSRF (walks every unsafe mounted route, pins 11), TestPhase101Actions/cookie_POSTs_need_X-Requested-With, TestPhase101AlbumsSmoke/toolbar (fonoteka); stage --security |
pass; removal check RC-03 fails TestPhase10CSRF and TestPhase101Actions | Same as Phase 10 T-10-02: relies on browsers not sending custom headers cross-origin without a CORS preflight |
| T-10.1-05 | Elevation of Privilege | action execution | high | mitigate | protect() checks the controller permissions, then allowAction checks the action's own Permissions (403, logged). compileContributions validates action permission codes at boot. listSchema filters toolbarActions to what the principal may run |
TestPhase101Actions/action_permission_on_top_of_the_controller's (403 on widget and toolbar, empty toolbarActions), TestPhase101Toolbar/labels_localize…permissions, TestPhase101FormExtensionSchema/unknown_action_permission…, TestPhase09PermissionMatrix; stages --security, --postgres |
pass; removal checks RC-04 and RC-05 fail TestPhase101Actions and TestPhase101Toolbar | None known |
| T-10.1-06 | Elevation of Privilege | record_id on widget POST and ?id= on partial GET (IDOR) | high | mitigate | cabana/actions.go readScopedRecord loads through the controller's FormExtendQuery; missing and out-of-scope ids are both 404. partial accepts ?id= only for a form partial and only a positive integer. Toolbar bodies must be {} |
TestPhase101Actions (out-of-scope and missing widget record 404 without running the action; ?id= on a header partial, abc, 0, -1, out-of-scope all 404; toolbar record_id 422); stage --postgres |
pass; removal check RC-06 fails TestPhase101Actions | None known |
| T-10.1-07 | Tampering | fill write-back (mass assignment) | high | mitigate | Boot requires every fill key to be a writable scalar field of the same form (compileExtension). onlyFillScalars drops non-fill keys and non-scalar values from both the action's Values and its Fill. A later save still runs ProjectWritableFields and the model rules |
TestPhase101Actions (action receives only fill-key scalars; response drops tenant, group, id and a non-scalar), TestPhase101FormExtensionSchema (protected collection_id, relation, repeated and unknown fill keys fail boot); stages --security, --postgres |
pass; removal checks RC-07 and RC-08 fail TestPhase101Actions and TestPhase101FormExtensionSchema | None known |
| T-10.1-08 | Tampering | partial output (XSS, server half) | high | mitigate | cabana/partial_render.go: html/template contextual escaping of view-model data, then html.ParseFragment and sanitizePartialNodes: an element allowlist, 19 tags dropped with their subtree, other elements unwrapped, a per-tag attribute allowlist plus aria-*/data-*, safePartialURL for a[href] and img[src], comments dropped. No HTML string leaves the server |
TestPhase101PartialSanitizer (every dropped tag, unwrapping, attribute table, 16 URL cases, comments, view-model markup stays text); stage --security |
pass; removal checks RC-09 and RC-10 fail TestPhase101PartialSanitizer | The allowlist is only as good as x/net/html's parse; the client re-checks every node (T-10.1-14) |
| T-10.1-09 | Information Disclosure | partial view models | medium | mitigate | pact.AdminPartialData documents a curated view model. refusedViewModel refuses the controller's model type, a pointer, slice, array or map of it, and any type carrying html/template trusted content types; the handler answers a logged 500. Records are loaded by cabana, not by the plugin |
TestPhase101PartialSanitizer/view-model_guard, TestPhase101PartialSanitizer/the_partial_route_refuses_a_model_view_model; stage --security |
pass; removal check RC-11 fails TestPhase101PartialSanitizer | A plugin can still copy a sensitive column into a curated struct; review of plugin view models stays a code-review duty |
| T-10.1-10 | Elevation of Privilege | Albums plugin JS running in the admin origin | medium | mitigate | Plugin JS is trusted compiled code (like plugin Go); the element makes no network request and reads no cookie or storage, and signals only through summer-action (D-05); the session cookie is HttpOnly. hygiene_101 refuses fetch(, XMLHttpRequest, document.cookie, localStorage, sessionStorage, indexedDB, sendBeacon, WebSocket and EventSource in any application plugins/*/*/assets/**/*.js |
check-phase10.1.sh --hygiene, --self-test (network, cookie and storage plants each refused for this rule only); stage --hygiene |
pass; removal check RC-19 (rule disabled) fails --self-test |
A determined plugin author can obfuscate a call past a grep; plugin JS stays trusted code |
| T-10.1-11 | Tampering | custom-element name collisions across plugins | low | mitigate | checkWidgetTag: the valid-name pattern (lowercase, a hyphen), the reserved-name list, and the owning plugin's {vendor}-{plugin}- prefix |
TestPhase101FormExtensionSchema (no hyphen, uppercase, another plugin's prefix, font-face-src under plugin font.face); stage --security |
pass | None known |
| T-10.1-12 | Denial of Service | partial rendering | medium | mitigate | cappedBuffer (64 KiB), partialBudget (2000 nodes) and the depth-32 check return errors, never a truncated tree; the handler logs and answers 500 |
TestPhase101PartialSanitizer/caps (at-cap passes, one over fails for size, nodes and depth); stage --security |
pass; removal check RC-12 fails TestPhase101PartialSanitizer | A slow PartialData query is not capped by these limits |
| T-10.1-13 | Tampering | pluginAssets loader (foreign script URL from a schema) | medium | mitigate | admin/src/app/pluginAssets.ts assetAllowed refuses any URL outside ${runtime.base}/assets/, dot segments (including percent-encoded ones, fixed in this plan), backslashes, whitespace and control characters; CSP script-src 'self' backs it up |
tests/app/pluginAssets.test.ts (18 refused URL shapes, no element appended for a refused URL); stage --spa |
pass; removal check RC-20 fails the suite | Schema URLs come from the server; the check is defence in depth |
| T-10.1-14 | Tampering | PartialHost node rendering (XSS, client half) | high | mitigate | admin/src/components/partial/partialNodes.ts rebuilds only allowlisted tags and attributes with h(), drops removed tags with their subtree, unwraps unknown ones, keeps text as text and never parses strings. The Phase 10 hygiene rule refuses v-html/innerHTML/insertAdjacentHTML; hygiene_101 refuses setHTML, setHTMLUnsafe, createContextualFragment, DOMParser, srcdoc and document.write in admin/src |
tests/list/PartialHost.test.ts (every allowed and dropped tag, attribute and URL table incl. javascript:, depth and node caps, text stays text), tests/smoke/extension.smoke.test.ts; stages --spa, --hygiene (proven by the --self-test parser and document.write plants) |
pass; removal checks RC-13 and RC-14 fail tests/list/PartialHost.test.ts | Vue's own escaping of text nodes is trusted |
| T-10.1-15 | Information Disclosure | Albums statistics strip | high | mitigate | fonoteka albumsAdminController.statsView builds each count query fresh and passes it through scopeAlbums (collection binding, 1 = 0 when unbound); the view model holds labels and integers only |
TestPhase101AlbumsExtension (two collections: empty admin collection shows All albums 0 while the other has four; No shelf only above zero), TestPhase101AlbumsSmoke/stats (fonoteka); stage --postgres |
pass; removal check RC-15 fails TestPhase101AlbumsExtension | None known |
| T-10.1-16 | Tampering | plugin CSS bleeding across controllers | low | mitigate | activateStyles disables stylesheet links owned by other controllers on every list and form mount |
tests/app/pluginAssets.test.ts (enable own, disable others, unknown controller disables all), tests/smoke/extension.smoke.test.ts (controller stylesheets); stage --spa |
pass; removal check RC-21 fails the suite | Plugin CSS still applies globally while its controller is open |
| T-10.1-17 | Tampering | client-side fill write-back | medium | mitigate | WidgetField patches only keys in field.fill that the response returns; the save still goes through the server's writable projection |
tests/form/WidgetField.test.ts (undeclared weight never patched), tests/form/FormView.test.ts (patch acts like an edit); stage --spa |
pass; removal check RC-22 fails tests/form/WidgetField.test.ts | None known |
| T-10.1-18 | Information Disclosure | plugin custom element receiving credentials | high | mitigate | WidgetField sets attributes only (record id, field name, locale, fill snapshot, labels) and performs the POST itself through the typed client; no property or function is assigned; the session cookie stays HttpOnly and no token exists in JS (Phase 10 T-10-01) |
tests/form/WidgetField.test.ts (exact attribute set, Object.keys(element) empty), tests/smoke/extension.smoke.test.ts; stage --spa |
pass; removal check RC-16 fails tests/form/WidgetField.test.ts | The element runs in the admin origin and could still call the API with the cookie; see T-10.1-10 |
| T-10.1-19 | Repudiation | Phase 10.1 acceptance evidence | high | mitigate | scripts/check-phase10.1.sh: phase101_detect refuses failed, skipped, zero-test, non-JSON and build-failed runs and required tests that did not pass; OpenAPI and dist drift, hygiene and evidence stages; this review names a failing-when-broken test and a removal check per high threat |
scripts/check-phase10.1.sh --self-test (pass, fail, skip, zero, non-JSON, build, build flag, package, required, required-failed, allowed, allowed-other, wrong-package, now-passes cases; eight hygiene plants); stage --all |
pass; removal check RC-17 (skip refusal removed) fails --self-test |
None known |
| T-10.1-20 | Tampering | framework/app boundary and extension conventions | low | mitigate | --hygiene runs the Phase 10 rules (check-phase10.sh --hygiene, including no application names in modules/cabana tests and testdata) plus hygiene_101: HTML-string parsers in admin/src, network, cookie or storage access in application plugin asset JS, and script, style= or inline on…= markup in application partial templates |
check-phase10.1.sh --hygiene, --self-test (each plant refused for its own rule only; clean look-alike markup accepted); stage --hygiene |
pass; removal check RC-19 fails --self-test |
Greps can be evaded by obfuscation; the rules catch mistakes, not an adversarial plugin |
| T-10.1-21 | Elevation of Privilege | discogsLookup and discogsSync stubs | medium | mitigate | Both require golem15.fonoteka.access_albums on top of the controller permission; a Genres-only admin gets 403 (Task 3) | TestPhase101AlbumsExtension/a_Genres-only_admin… (403 on widget, toolbar and partial; both actions declare the permission), TestPhase101AlbumsSmoke/toolbar (fonoteka); stage --postgres |
pass; removal check RC-18 fails TestPhase101AlbumsExtension | None known |
| T-10.1-22 | Tampering | stub fill payload | low | accept | The stub only patches the unsaved form; nothing persists until the admin saves, and the save runs the Album rules (year between 1889 and 2100, format in the option list). | Evidence: TestPhase101AlbumsExtension/Discogs_widget… (the action persists nothing; the save persists year 1977 and format LP) |
pass | Accepted: Phase 14 replaces the stub with the Discogs client |
| T-10.1-SC | Tampering | npm and Go dependencies | high | mitigate | No npm package added or re-pinned in Phase 10.1 (git diff --quiet b2845e0 -- admin/package.json admin/package-lock.json); no coverage provider. The only Go change is golang.org/x/net promoted from indirect to direct at the same v0.58.0 (Plan 10.1-01, named by RESEARCH); no fonoteka.go module change. --spa installs with npm ci from the committed lockfile; swag stays pinned at v1.16.6 in check-admin-openapi.sh |
check-phase10.1.sh --spa (npm ci), check-admin-openapi.sh --check (stage --openapi), check-admin-dist.sh (stage --dist) |
pass; removal check RC-23: a package.json entry missing from the lockfile makes npm ci exit 1 (EUSAGE) |
Transitive updates inside existing pins are not reviewed here |
Removal checks
Each row changed one production file (or a scratch copy of the gate), ran the named command, and restored the file byte for byte (git status clean afterwards). Go checks were driven by a scratch harness that refuses to run when its anchor text is not found exactly once. A mitigation counts only if its test fails.
| Check | Threat | Mutation | Command | Observed |
|---|---|---|---|---|
| RC-01 | T-10.1-01 | compileClientAssets no longer calls checkAssetPath |
go test ./modules/cabana -run '^TestPhase101Assets$' -count=1 |
exit 1, --- FAIL: TestPhase101Assets |
| RC-02 | T-10.1-01 | pluginAsset serves some declared file on a key miss instead of falling through |
go test ./modules/cabana -run '^TestPhase101Assets$' -count=1 |
exit 1, --- FAIL: TestPhase101Assets |
| RC-03 | T-10.1-04 | the widget route is mounted without requireAjax |
go test ./modules/cabana -run '^(TestPhase101Actions|TestPhase10CSRF)$' -count=1 |
exit 1, --- FAIL: TestPhase10CSRF |
| RC-04 | T-10.1-05 | widgetAction skips allowAction |
go test ./modules/cabana -run '^TestPhase101Actions$' -count=1 |
exit 1, --- FAIL: TestPhase101Actions |
| RC-05 | T-10.1-05 | listSchema offers every registered toolbar action regardless of permission |
go test ./modules/cabana -run '^TestPhase101Toolbar$' -count=1 |
exit 1, --- FAIL: TestPhase101Toolbar |
| RC-06 | T-10.1-06 | readScopedRecord skips the controller's FormExtendQuery |
go test ./modules/cabana -run '^TestPhase101Actions$' -count=1 |
exit 1, --- FAIL: TestPhase101Actions |
| RC-07 | T-10.1-07 | onlyFillScalars returns every key and value unfiltered |
go test ./modules/cabana -run '^TestPhase101Actions$' -count=1 |
exit 1, --- FAIL: TestPhase101Actions |
| RC-08 | T-10.1-07 | the boot check that a fill key is a writable scalar field is disabled | go test ./modules/cabana -run '^TestPhase101FormExtensionSchema$' -count=1 |
exit 1, --- FAIL: TestPhase101FormExtensionSchema |
| RC-09 | T-10.1-08 | sanitizePartialNode ignores partialDroppedTags (they are unwrapped instead) |
go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1 |
exit 1, --- FAIL: TestPhase101PartialSanitizer |
| RC-10 | T-10.1-08 | safePartialURL accepts every non-empty URL |
go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1 |
exit 1, --- FAIL: TestPhase101PartialSanitizer |
| RC-11 | T-10.1-09 | the partial handler ignores refusedViewModel |
go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1 |
exit 1, --- FAIL: TestPhase101PartialSanitizer |
| RC-12 | T-10.1-12 | partialBudget.take never runs out |
go test ./modules/cabana -run '^TestPhase101PartialSanitizer$' -count=1 |
exit 1, --- FAIL: TestPhase101PartialSanitizer |
| RC-13 | T-10.1-14 | renderPartialNodes no longer drops PARTIAL_DROPPED_TAGS |
npm --prefix admin test -- tests/list/PartialHost.test.ts |
exit 1, 20 of 127 tests fail |
| RC-14 | T-10.1-14 | partialAttrAllowed allows every attribute name |
npm --prefix admin test -- tests/list/PartialHost.test.ts |
exit 1, 10 of 127 tests fail |
| RC-15 | T-10.1-15 | the stats queries skip scopeAlbums |
fonoteka go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1 |
exit 1, --- FAIL: TestPhase101AlbumsExtension |
| RC-16 | T-10.1-18 | WidgetField also assigns the API client to the element (Object.assign(created, { api })) |
npm --prefix admin test -- tests/form/WidgetField.test.ts |
exit 1, 1 of 22 tests fails |
| RC-17 | T-10.1-19 | phase101_detect no longer refuses a skipped test (scratch copy of the gate) |
bash <copy> --self-test |
exit 1, refuse: self-test skip: detector exit 3, want 2 |
| RC-18 | T-10.1-21 | discogsSync declares no permission of its own |
fonoteka go test ./plugins/golem15/fonoteka -run '^TestPhase101AlbumsExtension$' -count=1 |
exit 1, --- FAIL: TestPhase101AlbumsExtension |
| RC-19 | T-10.1-10, T-10.1-20 | hygiene_101 no longer reports the plugin asset rule (scratch copy of the gate) |
bash <copy> --self-test |
exit 1, refuse: self-test hygiene_101 accepted a planted network |
| RC-20 | T-10.1-13 | assetAllowed drops the {base}/assets/ prefix check |
npm --prefix admin test -- tests/app/pluginAssets.test.ts |
exit 1, 11 of 30 tests fail |
| RC-21 | T-10.1-16 | activateStyles enables every link |
npm --prefix admin test -- tests/app/pluginAssets.test.ts |
exit 1, 2 of 30 tests fail |
| RC-22 | T-10.1-17 | WidgetField patches every key the response returns |
npm --prefix admin test -- tests/form/WidgetField.test.ts |
exit 1, 1 of 22 tests fails |
| RC-23 | T-10.1-SC | a scratch copy of admin/package.json gains @vitest/coverage-v8 without a lockfile update |
npm ci --no-audit --no-fund --dry-run in the scratch copy |
exit 1, EUSAGE … package.json and package-lock.json are in sync |
Two removal checks initially survived and led to stronger tests before this review was written: the model-type guard (RC-11) passed because the header template failed on the model anyway, so the test now renders the form partial whose template reads a field the model also has; and the Discogs action permission (RC-18) was masked by the controller permission, so the acceptance test now asserts the permission each action declares.
Findings fixed during the review
- Percent-encoded dot segments in plugin asset URLs (T-10.1-13).
assetAllowedrefused..but accepted%2e%2e, which the URL parser resolves as.., so{base}/assets/%2e%2e/api/v1/...escaped the asset prefix. Fixed in6b0ac15with its suite and a rebuilt dist. - Stale parity allow-list in the gates. The two fonoteka parity failures the Phase 10 gate allow-listed pass since fonoteka.go
21c0f12, and the detector refuses an allow-listed failure that passes, socheck-phase10.sh --gofailed. Both gates now allow-list nothing.
Phase 10 threats revisited
- T-10-16 (SPA rendering, XSS). Its residual risk now reads: plugin HTML reaches the DOM only as a sanitized node tree. The server allowlist (T-10.1-08) and the client
h()rebuild (T-10.1-14) both apply; no raw-HTML sink was added to admin/src. - T-10-04 (framing, sniffing). Plugin assets reuse
boardwalk.SetSecurityHeadersunchanged; the CSP needed no carve-out for plugin scripts because they are served same-origin under{base}/assets/.
Residual risk
- Plugin JS and Go run with the admin's authority by design (compiled plugins, D-04). The hygiene rules catch mistakes in application assets and partials, not a hostile plugin.
- Real-browser behaviour (CSP enforcement of module scripts, custom-element upgrade, the 768px layout backstops, the Vite
/assetsdev proxy) is outside what happy-dom can prove; it stays with the human checks of 10.1-02 Task 3 and 10.1-03 Task 3 at/gsd-verify-work.