Files
summercms/.planning/phases/10.1-runtime-admin-extension-point/10.1-SECURITY-REVIEW.md
Jakub Zych bbceeb957f docs(10.1-04): record the Phase 10.1 security review and validation map
- 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
2026-09-29 03:13:18 +02:00

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). assetAllowed refused .. but accepted %2e%2e, which the URL parser resolves as .., so {base}/assets/%2e%2e/api/v1/... escaped the asset prefix. Fixed in 6b0ac15 with 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, so check-phase10.sh --go failed. 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.SetSecurityHeaders unchanged; 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 /assets dev 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.