docs(quick-261012-s3g): Phase 10 security blockers plan, summary and deferred fonoteka.go route allowlist

This commit is contained in:
2026-10-12 21:07:05 +02:00
parent 708b3ec581
commit ce088855ee
4 changed files with 732 additions and 4 deletions

View File

@@ -5,10 +5,10 @@ current_phase: 17
current_phase_name: Blog companions
status: verifying
stopped_at: Completed 17-06-PLAN.md
last_updated: "2026-10-12T17:53:57.388Z"
last_updated: "2026-10-12T19:06:59.056Z"
last_activity: 2026-10-12
last_activity_desc: Phase 17 execution started
state_head: 3a0c4f72d799d0c1051fb21bbb22627e33dfe72c
last_activity_desc: "Completed quick task 261012-s3g: Phase 10 security blockers (DOMPurify, check-phase10 hygiene)"
state_head: 708b3ec581287756baed7962f7a29b3f95265602
progress:
total_phases: 40
completed_phases: 14
@@ -32,7 +32,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
Phase: 17 (Blog companions) — EXECUTING
Plan: 6 of 6
Status: Phase complete — ready for verification
Last activity: 2026-10-12 — Phase 17 execution started
Last activity: 2026-10-12 — Completed quick task 261012-s3g: Phase 10 security blockers (DOMPurify, check-phase10 hygiene)
Progress: [████░░░░░░] 44%
@@ -808,6 +808,7 @@ Recent decisions affecting current work:
| 30 | go test -short runs without Docker: TestMain flag parsing fix in 4 modules, cabana list fixture skip | 2026-10-11 | 7ef2e4f, c85cb64 | — |
| 261011-kzi | Fix Phase 09 gaps: fonoteka phase09AdminRoutes inventory + per-guard cabana last_used_at throttle | 2026-10-11 | b45af8e | [261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-](./quick/261011-kzi-fix-phase-09-gaps-add-the-10-post-phase-/) |
| 261011-mag | fix check-phase10.sh hygiene gaps from 10-VERIFICATION.md | 2026-10-11 | 1541cd4 | [261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1](./quick/261011-mag-fix-check-phase10-sh-hygiene-gaps-from-1/) |
| 261012-s3g | Fix Phase 10 security blockers: DOMPurify richeditor display sanitizer (T-10-16), check-phase10 hygiene green (T-10-24/25), CSP form-action/frame-src; --all still red on fonoteka.go TestPhase09SecurityRoutes | 2026-10-12 | 708b3ec | [261012-s3g-fix-phase-10-security-blockers-t-10-16-d](./quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/) |
## Deferred Items

View File

@@ -0,0 +1,523 @@
---
phase: quick-261012-s3g
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
- .planning/research/STACK.md
- admin/package.json
- admin/package-lock.json
- admin/vitest.config.ts
- admin/vitest.browser.config.ts
- admin/src/components/form/richeditor/html.ts
- admin/tests/browser/richeditor-display.test.ts
- admin/tests/form/RichEditorField.test.ts
- admin/tests/views/ReorderView.test.ts
- README.md
- CLAUDE.md
- scripts/check-phase10.sh
- scripts/check-phase17.sh
- modules/boardwalk/boardwalk.go
- modules/boardwalk/boardwalk_test.go
- modules/boardwalk/README.md
- docs/backend/admin-spa.md
- modules/boardwalk/dist
- .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
autonomous: true
requirements: [QUICK-261012-s3g]
estimate:
tokens: 125000
raw_tokens: 125000
tasks: 3
confidence: low
must_haves:
truths:
- "displayHtml (admin/src/components/form/richeditor/html.ts) sanitizes through a dedicated DOMPurify 3.4.16 instance: ALLOWED_TAGS = EDITOR_TAGS (lossy.ts), ALLOWED_ATTR = ['href'], ALLOW_DATA_ATTR false, ALLOW_ARIA_ATTR false, an uponSanitizeAttribute hook that keeps href only on <a> and only when isAllowedLink accepts it, ADD_FORBID_CONTENTS = the DROPPED list, KEEP_CONTENT true, string output. The hand-rolled clean() is gone (T-10-16, T-17-10)."
- "displayHtml parses into DOMPurify's inert DOMParser document and returns a string. No node is adopted into the live document before Vue binds the value, and no image request starts inside displayHtml (the html.ts:102 adoption is removed)."
- "displayHtml fails closed. Where DOMPurify is unsupported, or a fixed canary does not sanitize to its known answer (as under happy-dom, where DOMPurify leaves nested nodes unsanitized), it returns the empty string and never the stored value."
- "`npm --prefix admin run test:browser` runs admin/tests/browser/*.test.ts in Playwright Chromium (Vitest browser mode) and passes. It covers form/input name=nodeType with img onerror, img name=nodeType, button name=nodeType, the same nested in a div, input name=localName (no throw), an off-origin form action with a password input, iframe, meta refresh, javascript: href, an href outside the link allowlist (ftp:, sms:), href on a non-<a> element, the inert-parse (no request) check and the read-only RichEditorField mount. Run against the pre-fix html.ts, the suite fails (RED recorded in the SUMMARY)."
- "`npm --prefix admin test` (happy-dom) and `npm --prefix admin run typecheck` pass. The happy-dom suite excludes tests/browser and keeps only environment-independent 'never as stored' assertions for the read-only view."
- "The admin CSP (modules/boardwalk/boardwalk.go contentSecurityPolicy) is `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`. The boardwalk header tests, modules/boardwalk/README.md and docs/backend/admin-spa.md say so."
- "`bash scripts/check-phase10.sh --hygiene`, `--self-test` and `--all` each exit 0. The raw-HTML rule exempts exactly two more lines, both line-exact and commented with T-17-10: the RichEditorField.vue read-only binding and the lossy.ts inert-template parse. The dist-origin rule additionally allows exactly `https://prosemirror.net` and `https://admin.invalid`. context.ts, ReorderBranch.vue and ReorderView.vue are imported by path in a test."
- "`bash scripts/check-phase17.sh` runs `check-phase10.sh --hygiene` (docs stage) and the browser suite with required test names (admin stage), and `--all` passes (postgres per Docker availability)."
- "modules/boardwalk/dist is rebuilt from the new sources and committed. `scripts/check-admin-dist.sh`, `go vet ./...` and `go test ./...` pass."
- "Decision note .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md and the STACK.md rows name dompurify 3.4.16, @vitest/browser 3.2.7 and playwright 1.60.0 with legitimacy evidence. 10-SECURITY-REVIEW.md is refreshed (reviewed 2026-10-12) with a removal check for the clobbering vector."
artifacts:
- path: admin/src/components/form/richeditor/html.ts
provides: "DOMPurify-backed displayHtml with the isAllowedLink hook and a fail-closed canary"
contains: "dompurify"
- path: admin/vitest.browser.config.ts
provides: "Vitest browser mode, playwright provider, chromium instance, include tests/browser/**/*.test.ts"
contains: "playwright"
- path: admin/tests/browser/richeditor-display.test.ts
provides: "real-engine regression suite for the clobbering vectors and the read-only RichEditorField"
contains: "src/components/form/richeditor/html'"
- path: .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
provides: "decision note for dompurify, @vitest/browser and playwright"
contains: "3.4.16"
- path: scripts/check-phase10.sh
provides: "T-17-10 line-exact raw-HTML exceptions, two allow-listed dist literals, the browser suite in --spa, new self-test plants"
contains: "T-17-10"
- path: scripts/check-phase17.sh
provides: "cross-phase check-phase10 --hygiene run and the browser-suite requirement list"
contains: "check-phase10.sh"
- path: modules/boardwalk/boardwalk.go
provides: "hardened admin CSP"
contains: "form-action 'self'; frame-src 'none'"
key_links:
- from: admin/src/components/form/fields/RichEditorField.vue
to: admin/src/components/form/richeditor/html.ts
via: "display = computed(() => readOnly ? displayHtml(stored) : '') bound at RichEditorField.vue:305"
pattern: "displayHtml\\(stored.value\\)"
- from: admin/src/components/form/richeditor/html.ts
to: dompurify
via: "dedicated instance (DOMPurify(window)) with the uponSanitizeAttribute hook calling isAllowedLink"
pattern: "uponSanitizeAttribute"
- from: scripts/check-phase10.sh run_spa
to: admin/vitest.browser.config.ts
via: "npm --prefix admin run test:browser, fail-closed on FAIL/skipped/No test files"
pattern: "test:browser"
- from: scripts/check-phase17.sh run_docs
to: scripts/check-phase10.sh
via: "bash \"$ROOT/scripts/check-phase10.sh\" --hygiene"
pattern: "check-phase10.sh\" --hygiene"
---
<objective>
Close the Phase 10 security blockers from `.planning/phases/10-admin-vue-spa/10-SECURITY.md` (g15office u44mzmlf). The full task text is `temp/sec_phase_10.md`; every numbered item and sub-bullet there is in scope, including the optional CSP hardening.
- **T-10-16 / T-17-10 (high):** a DOM-clobbering bypass of the hand-rolled `clean()` in `admin/src/components/form/richeditor/html.ts`, bound through `v-html` at `RichEditorField.vue:305`. Fix it with DOMPurify plus a real-browser regression suite, and harden the admin CSP.
- **T-10-24 (high) / T-10-25 (low):** `scripts/check-phase10.sh --hygiene` and `--self-test` are red since Phase 17. Make them green and make `check-phase17.sh` run the Phase 10 hygiene so cross-phase drift is caught.
- Rebuild and commit the embedded admin dist, pass every gate, and refresh `10-SECURITY-REVIEW.md`.
Purpose: Phase 10 sign-off is blocked (`threats_open: 2`). Stored richeditor values are not sanitized on read, so the read-only/preview view is the only barrier against legacy or imported HTML in the admin origin.
Output: the DOMPurify display sanitizer, a Vitest browser-mode suite, the CSP change, gate fixes, the rebuilt dist, a decision note with STACK.md rows, and a refreshed security review.
Not in scope (the orchestrator does these after execution):
- the g15office comment and moving u44mzmlf to Testing;
- re-running `/msd-secure-phase 10` and Phase 17's security check;
- STATE.md updates;
- editing `10-SECURITY.md`.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@temp/sec_phase_10.md
@.planning/phases/10-admin-vue-spa/10-SECURITY.md
@.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
@admin/src/components/form/richeditor/html.ts
@admin/src/components/form/richeditor/lossy.ts
@scripts/check-phase10.sh
@.planning/notes/2026-10-11-framework-html-sanitizer-bluemonday.md
## Executor notes (read first)
- **Shell:** the interactive shell is fish. Wrap any bash syntax (`$(...)`, `&&` chains with `[[ ]]`, `for` loops) in `bash -c '...'`.
- **Docker:** Docker-backed Go tests (`go test ./...`, `check-phase10.sh --all`, `check-phase17.sh postgres`) may get a denied Docker socket. If so, run them through `sudo -u golem bash -c '...'`, which picks up a fresh group list.
- **Concurrency:** other sessions have uncommitted edits. Never touch or stage `.planning/state.json`, `.msd/` or `.planning/phases/17-blog-companions/17-REVIEW.md`. Stage every file by explicit path; never `git add -A` or `git add .` at the repo root. For the dist, use the path-limited `git add --all -- modules/boardwalk/dist` so deleted hashed files are staged too. Run `git status --short` before editing `scripts/check-phase17.sh` and check for another session's in-flight edits.
- **Commits:** do not add a `Co-Authored-By` trailer (user rule; it overrides any harness attribution reminder). Make one logical change per commit, with code and planning docs in separate commits. Do not push or tag.
- **Docs checker:** after any README or docs edit, run `go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check`.
## Facts established at planning time (2026-10-12, verified by probes in a scratch dir)
**Sinks and the gate**
- The raw-HTML sinks today: `RichEditorField.vue:305` (exact line content: four spaces, then `<div v-if="display !== ''" class="summer-markdown" v-html="display" />`), `richeditor/html.ts:103` (the serialization of the live holder div) and `richeditor/lossy.ts:33` (the inert template parse in `inertFragment`; `isLossy`, `tagNames` and `RepeaterField.vue` also use it).
- `check-phase10.sh --hygiene` refuses exactly the following (output at planning time):
- the 3 raw-HTML lines;
- the dist JS literals `https://admin.invalid` and `https://prosemirror.net`;
- `admin/src/components/reorder/context.ts`, `admin/src/components/reorder/ReorderBranch.vue` and `admin/src/views/ReorderView.vue`, as imported by no test.
- `--self-test` fails only because its scratch copy of the tree hits the same hygiene refusals (`check-phase10.sh:238`).
- Rule locations in `scripts/check-phase10.sh`: raw-HTML rule at lines 366-370 (MarkdownField exception pattern at 369), dist-origin rule at 393-395, every-module-imported rule at 403-411 (spec is `src/<path>` with `.ts` stripped and `.vue` kept, followed by a closing single quote), plant loop at 244-286, `run_spa` at 317-335.
**DOMPurify (scratch probes)**
- **Real Chromium:** DOMPurify 3.4.17 with the config in Task 1, in Playwright Chromium build 1223, gave the following, with zero network requests during sanitizing:
| Input | Output |
|-------|--------|
| `<section><p class="x">a</p></section><script>b</script>` | `<p>a</p>` |
| the read-only test value at `RichEditorField.test.ts:159` | `<p>Hi <a>bad</a> <a href="/ok">ok</a></p>` |
| form/input name=nodeType + img onerror | empty string |
| img name=nodeType | empty string |
| button name=nodeType + iframe | empty string |
| `<div><form><input name="nodeType"><meta http-equiv=refresh …></form></div>` | `<div></div>` |
| `<form><input name="localName"><p>t</p></form>` | `<p>t</p>` |
| off-origin form + password input + button Go | `Go` |
| iframe + meta refresh + `javascript:` link + style | `<a>x</a>` |
| `<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>` | `<p><b>a</b><a>d</a></p>` |
- **happy-dom 20.11.6:** DOMPurify does NOT sanitize. Inside a vitest happy-dom environment it reports `isSupported` true, but it visits only top-level nodes:
- the `<section>` input above returns `<p class="x">a</p><script>b</script>`;
- `<p>one</p>` returns `one`;
- the clobbering vectors come back nearly intact.
This is why Task 1 adds a canary guard, and why exact display assertions live only in the browser suite.
- **Vite build:** building DOMPurify with the admin's Vite adds only the literal `http://www.w3.org` to the JS, which the dist-origin rule already allows.
**Browser for the test suite**
- Playwright browsers already on disk: `~/.cache/ms-playwright/chromium-1223` and `chromium_headless_shell-1223`, installed by playwright-core 1.60.0. Pinning `playwright` 1.60.0 needs no browser download.
- If Playwright still reports a missing executable, run `npx --prefix admin playwright install chromium` once and note it in the SUMMARY.
**CSP probe (headless Chromium 1223)**
- Under the full proposed CSP, `frame-src 'none'` blocks an `<iframe src>`, but a `sandbox=""` srcdoc iframe still renders, and its same-origin images load.
- So the sm-journal-plugin Blog Editor preview pane (`assets/src/blogeditor/element.ts:325`, sandboxed srcdoc) keeps working.
- No iframe or native off-origin form post exists in `admin/src`.
**Package legitimacy (registry checks at planning time)**
| Package | Version | Publisher / repo | Released | Verdict |
|---------|---------|------------------|----------|---------|
| dompurify | 3.4.16 | cure53 (sole maintainer since the start), github.com/cure53/DOMPurify, license MPL-2.0 OR Apache-2.0, dep `@types/trusted-types` | 2026-09-23 | [VERIFIED]. The lockfile integrity must equal `sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==`. |
| @vitest/browser | 3.2.7 | vitest-dev, github.com/vitest-dev/vitest | 2026-07-06 | [VERIFIED]. It must match the installed vitest 3.2.7 exactly (vitest's own peerDependency). |
| playwright | 1.60.0 | Microsoft, github.com/microsoft/playwright, dep playwright-core 1.60.0 | 2026-05-11 | [VERIFIED]. |
- **Why 3.4.16 and not 3.4.17:** 3.4.17 is the latest, but it was published 2026-10-12, hours before planning. Its release notes list `IN_PLACE`/hook fixes and edge-case hardening, with no security advisory. Pin 3.4.16 under a 7-day release cooldown and record 3.4.17 as the next bump in the decision note.
**Precedents**
- **Decision notes:** named `.planning/notes/YYYY-MM-DD-<slug>.md`, with H1 `# Decision: ...`, then Date, Status and Relates-to lines, then the sections Decision, Why, Consequences. See the bluemonday note.
- **STACK.md:** has no admin/npm section yet (sections at lines 11-58).
- **Dist rebuilds:** committed separately as `build(...)` commits, e.g. 01bcd3e.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: DOMPurify display sanitizer with a real-browser regression suite (T-10-16, T-17-10)</name>
<files>.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md, .planning/research/STACK.md, admin/package.json, admin/package-lock.json, admin/vitest.config.ts, admin/vitest.browser.config.ts, admin/src/components/form/richeditor/html.ts, admin/tests/browser/richeditor-display.test.ts, admin/tests/form/RichEditorField.test.ts, README.md, CLAUDE.md</files>
<behavior>
Name the suite `describe('richeditor display in a real browser engine')`. check-phase17 requires these names in Task 2, so keep them exact.
- 'runs where form controls clobber form properties': in a DOMParser document, a form holding an input named nodeType reports a non-number nodeType. This proves the engine reproduces clobbering. It must fail if the file is ever run under happy-dom.
- 'drops clobbered forms with everything inside them': the vectors are form/input name=nodeType + img src onerror, img name=nodeType, button name=nodeType, and each of these nested in a div. Every output passes the policy check and contains no form, input, img, button, iframe or meta element and no on* attribute.
- 'keeps a form control named localName from breaking the view': displayHtml of `<form><input name="localName"><p>t</p></form>` does not throw and returns `<p>t</p>`.
- 'drops an off-origin form, iframes, meta refresh and disallowed links': the vectors are an off-origin form action with a password input, an iframe, a meta refresh and a javascript: href. The output keeps no such element, and every <a> loses a javascript:, ftp: or sms: href. An href on a <p> is dropped, while `<a href="docs/page">`, `<a href="mailto:a@example.test">` and `<a href="/ok">` keep theirs.
- 'parses into an inert document, so nothing loads before the value is bound': displayHtml runs on input with images pointing at a unique probe URL. A live control img must produce a resource-timing entry, which proves the observation works; the probe URL must produce none.
- The policy check helper parses an output with `inertFragment` from lossy.ts and requires two things. Every element's lower-cased localName must be in EDITOR_TAGS. The only attribute anywhere must be href on an a element, with isAllowedLink true.
- `describe('richeditor policy helpers')` 'unwraps unknown elements and drops scripts for display' moves here verbatim from RichEditorField.test.ts:186-189, with the exact `<p>a</p>` result.
- `describe('RichEditorField')` 'renders read-only values exactly through the display policy': mount RichEditorField read-only (field readOnly true, controlId f-body) with the RichEditorField.test.ts:159 value. The [data-richeditor-readonly] box has no script, no onclick, and a hrefs equal to [undefined, '/ok']. A second mount with the form/input name=nodeType + img onerror vector has no form, input or img in the box.
</behavior>
<action>
**A. Planning-docs commit first.** This project requires a decision note to name a new dependency before the dependency lands.
1. Re-run the legitimacy checks and paste the results into the note: `npm view dompurify@3.4.16 dist.integrity maintainers repository.url`, `npm view @vitest/browser@3.2.7 maintainers peerDependencies` and `npm view playwright@1.60.0 maintainers dependencies`. Stop and report if a maintainer, repository or integrity differs from the planning-time table in `<context>`.
2. Write `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md` in the format of the bluemonday note. It must cover:
- **Decision:**
- the admin SPA adds dompurify 3.4.16 (exact pin, runtime dependency) for the richeditor read-only display (T-10-16, T-17-10);
- it adds @vitest/browser 3.2.7 and playwright 1.60.0 (exact pins, devDependencies) for a Vitest browser-mode suite run as `npm --prefix admin run test:browser` from `admin/vitest.browser.config.ts`;
- list the sanitizer config from step C.
- **Why:**
- the clobbering bypass in the hand-rolled walker;
- the project's preference for a maintained library in security code (the bluemonday precedent);
- DOMPurify's clobbering checks and prototype-getter traversal;
- happy-dom cannot reproduce clobbering, and DOMPurify does not sanitize under happy-dom 20.11.6, hence the canary and the browser suite.
- **Scope vs Phase 10 D-23:** Playwright is used only as the Vitest browser provider for unit-level tests in a real engine. It does not implement the deferred Playwright e2e follow-up from 10-CONTEXT.md.
- **Legitimacy:** the evidence table from step 1.
- **Version choice:** 3.4.16 over 3.4.17 (7-day cooldown; 3.4.17 has no security advisory). Bump to 3.4.17 or later after 2026-10-19.
- **Consequences:**
- read-only display drops the content of DOMPurify's default content-forbidden elements (thead, colgroup, audio, video and others) on top of the DROPPED list;
- happy-dom tests can no longer assert exact display output;
- the browser suite needs the Playwright chromium build on the machine.
3. In `.planning/research/STACK.md`, add a subsection `### Admin SPA (npm) additions` after `### Development Tools`. Use the table shape Library | Version | Purpose | Notes, with rows for dompurify 3.4.16, @vitest/browser 3.2.7 (dev) and playwright 1.60.0 (dev), each pointing to the decision note.
4. Commit only these two files: `docs(quick-261012-s3g): decision note naming DOMPurify and Vitest browser mode for the admin`.
**B. Install with exact pins.**
1. Run `npm --prefix admin install --save-exact dompurify@3.4.16`, then `npm --prefix admin install --save-exact --save-dev @vitest/browser@3.2.7 playwright@1.60.0`, then `npm --prefix admin ci`.
2. Confirm all of the following:
- `git diff admin/package.json` adds exactly these three entries, with no caret;
- the dompurify integrity in `admin/package-lock.json` equals the value in `<context>`;
- `npm --prefix admin audit signatures` passes. If the registry cannot be reached, record that in the SUMMARY.
**C. Browser suite, then RED.**
1. In `admin/vitest.config.ts`, add an `exclude` of `configDefaults.exclude` plus `tests/browser/**`; import `configDefaults` from `vitest/config`.
2. Create `admin/vitest.browser.config.ts`:
- `defineConfig` from `vitest/config`, with the `vue()` plugin;
- `test.include` set to `['tests/browser/**/*.test.ts']`;
- `test.browser` set to `enabled: true`, `provider: 'playwright'`, `headless: true`, `screenshotFailures: false`, `instances: [{ browser: 'chromium' }]`;
- no setupFiles, since `tests/setup.ts` is a happy-dom shim.
Confirm these field names against the Vitest 3.2 browser-mode docs (Context7 or v3.vitest.dev/guide/browser). If the first run reloads on dependency optimization, add `optimizeDeps.include` for vue, @vue/test-utils and dompurify.
3. Add the script `"test:browser": "vitest run --config vitest.browser.config.ts"` to `admin/package.json`.
4. Write `admin/tests/browser/richeditor-display.test.ts` per `<behavior>`:
- import displayHtml and isAllowedLink by the path `../../src/components/form/richeditor/html`, EDITOR_TAGS and inertFragment from `../../src/components/form/richeditor/lossy`, and RichEditorField from `../../src/components/form/fields/RichEditorField.vue`;
- mount with @vue/test-utils;
- for the resource-timing check, use a token-unique path under the current origin, `performance.getEntriesByType('resource')`, and a control img created in the live document whose load/error event you await;
- do not use Polish catalogue words or application names, because the hygiene app-name rule scans admin/tests.
5. Run `npm --prefix admin run test:browser` against the CURRENT html.ts. It must fail at least on the clobbering and inert-document tests. Record the failing test names in the SUMMARY (RED).
**D. GREEN: rewrite html.ts.**
1. Keep:
- `LINK_SCHEMES`, `URL_IGNORED`, `URL_SCHEME` and `isAllowedLink` unchanged, including the `https://admin.invalid/` parse base (Task 2 allow-lists the literal);
- the DROPPED set, as the source of ADD_FORBID_CONTENTS;
- the `displayHtml(html: string): string` signature and its empty-string short-circuit.
2. Delete the `clean()` walker, the holder div and the `inertFragment` import.
3. Create a module-level dedicated instance by calling DOMPurify's default export with `window`, so the hook is not global. Register one `uponSanitizeAttribute` hook: when `data.attrName` is href and either the node's nodeName is not `A` or `isAllowedLink(data.attrValue)` is false, set `data.keepAttr = false`. Reading nodeName is safe here because DOMPurify removes clobbered forms before attributes are visited, and only form elements expose named controls.
4. Define one frozen config object, typed with DOMPurify's `Config`:
- `ALLOWED_TAGS` is a spread of `EDITOR_TAGS`;
- `ALLOWED_ATTR` is `['href']`;
- `ALLOW_DATA_ATTR` and `ALLOW_ARIA_ATTR` are false;
- `ADD_FORBID_CONTENTS` is a spread of DROPPED;
- `KEEP_CONTENT` is true;
- `RETURN_TRUSTED_TYPE` is false;
- never `RETURN_DOM`, `RETURN_DOM_FRAGMENT` or `IN_PLACE`, and never pass a Node. The string path parses with DOMParser into an inert document, so nothing is adopted into the live document.
5. Add a lazily computed, cached `sanitizerUsable()`. It is true only when the instance's `isSupported` is true AND sanitizing the canary `<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>` with the config returns exactly `<p><b>a</b><a>d</a></p>`. displayHtml returns '' when it is false, because DOMPurify returns its input unchanged where it cannot run.
6. Update the header comment to cite T-10-16, T-17-10 and the decision note. html.ts must not contain any of the four raw-HTML tokens the hygiene rule at `scripts/check-phase10.sh:368` greps for, not even inside a comment: the rule counts comment lines.
7. Re-run `npm --prefix admin run test:browser` until it is GREEN.
**E. Happy-dom suite (`admin/tests/form/RichEditorField.test.ts`).**
1. In 'renders read-only values through the display policy, never as stored' (lines 154-168), keep the name and make the assertions environment-independent:
- the box contains no script and no element with an onclick attribute;
- the box HTML has no `javascript:`;
- every remaining a href passes isAllowedLink;
- `window.__re` stays undefined.
Drop the `find('p').attributes` and exact href-list assertions; the browser suite owns them.
2. Delete 'unwraps unknown elements and drops scripts for display' (lines 186-189); it now lives in the browser suite.
3. Add a short comment saying happy-dom cannot run DOMPurify, so displayHtml binds nothing there (fail closed), and that exact display assertions live in `tests/browser/richeditor-display.test.ts`.
4. Run the full `npm --prefix admin test` and fix any other happy-dom test that asserted sanitized read-only richeditor content (planning grep found none outside this file; RepeaterField's read-only test checks structure only).
**F. Docs.**
1. In `README.md`, in the admin scripts block at lines 150-157, add `npm --prefix admin run test:browser # Vitest browser mode in Playwright Chromium (needs the chromium build; npx --prefix admin playwright install chromium once)`.
2. In `CLAUDE.md`, change the commands line `npm --prefix admin run test | typecheck | build | dev` to include `test:browser`.
3. Run the docs checker.
**G. Commit.** Commit the code by path (package.json, package-lock.json, both vitest configs, html.ts, both test files, README.md, CLAUDE.md) as `fix(admin): sanitize the richeditor read-only display with DOMPurify (T-10-16)`.
</action>
<verify>
<automated>npm --prefix admin run test:browser && npm --prefix admin test && npm --prefix admin run typecheck && bash -c '! grep -nE "v-html|innerHTML|outerHTML|insertAdjacentHTML" admin/src/components/form/richeditor/html.ts && grep -q "\"dompurify\": \"3.4.16\"" admin/package.json && grep -q "\"@vitest/browser\": \"3.2.7\"" admin/package.json && grep -q "\"playwright\": \"1.60.0\"" admin/package.json && grep -q "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==" admin/package-lock.json'</automated>
</verify>
<done>
- The browser suite fails on the pre-fix html.ts (names recorded) and passes on the DOMPurify html.ts.
- The happy-dom suite and typecheck pass.
- html.ts has no clean() walker, no live-document holder and no raw-HTML token.
- The three exact pins and the dompurify integrity are in the committed package.json and lockfile.
- The decision note and STACK.md rows are committed in their own docs commit, before the code commit.
</done>
</task>
<!-- planner-discipline-allow: v-html -->
<!-- planner-discipline-allow: innerHTML -->
<task type="auto">
<name>Task 2: Green check-phase10 hygiene and self-test, cross-phase check in check-phase17, reorder imports, CSP hardening (T-10-24, T-10-25, T-10-04)</name>
<files>scripts/check-phase10.sh, scripts/check-phase17.sh, admin/tests/views/ReorderView.test.ts, modules/boardwalk/boardwalk.go, modules/boardwalk/boardwalk_test.go, modules/boardwalk/README.md, docs/backend/admin-spa.md</files>
<action>
**A. `scripts/check-phase10.sh` hygiene.**
1. **Raw-HTML rule (lines 366-370).** Add two exceptions next to the MarkdownField one, each line-exact: anchored at the start and end, naming the file path and the full trimmed line content. Each gets a comment line naming T-17-10 and what makes it safe.
- `admin/src/components/form/fields/RichEditorField.vue`: its read-only display div, whose exact content is given in `<context>`. Comment: binds only displayHtml output, DOMPurify, T-10-16.
- `admin/src/components/form/richeditor/lossy.ts`: the template parse in inertFragment. Comment: template contents are inert and are read for tag names only, never bound.
The RichEditorField line contains two adjacent single quotes, so write that pattern as a double-quoted shell string (or a `-e` argument built from one) rather than inside the existing single-quoted pattern. Do not widen any existing exception, and add none for html.ts: it must have no raw-HTML token.
2. **Dist-origin rule (line 394).** Extend the `grep -vxE` allow-list with exactly `https://prosemirror\.net` and `https://admin\.invalid`. Add a comment: the first is ProseMirror error text; the second is the reserved-TLD parse base in isAllowedLink. Both are literals and are never requested.
3. **Self-test plant loop (lines 244-286).** Add three plants, each refused for its own rule and never for "imported by no test":
- `vhtml-elsewhere`: the exact RichEditorField display line written into `admin/src/__plant/Plant.vue`, imported by the scratch test. Want: raw-HTML directive. This proves the exception is bound to its path.
- `template-parse`: a `.ts` plant in `admin/src/__plant` assigning markup to a template element the same way lossy.ts does. Want: raw-HTML directive.
- `origin`: a file `modules/boardwalk/dist/assets/__plant.js` in the scratch copy containing a string literal `https://evil.example/x`. Want: "JS names another origin". Remove that file at the top of each loop iteration, alongside the existing rm.
4. **`run_spa` (lines 317-335).** After the happy-dom run, run `npm --prefix "$ROOT/admin" run test:browser` the same fail-closed way: capture the log, and refuse on a non-zero exit or on any of `No test files found`, `Unhandled`, `FAIL `, ` skipped` or ` todo`. Print the Test Files/Tests summary lines.
5. Update the header comment and the comment at line 367 so they describe the new exceptions.
**B. `scripts/check-phase17.sh`.**
1. Make `vitest_json` pass any extra arguments after `dir` to vitest, so `--config vitest.browser.config.ts` can be added.
2. Remove `richeditor policy helpers unwraps unknown elements and drops scripts for display` from `ADMIN_REQUIRE`; Task 1 moved it to the browser suite.
3. Add `ADMIN_BROWSER_FILES=(tests/browser/richeditor-display.test.ts)` and `ADMIN_BROWSER_REQUIRE` with the full names (describe + it) of every test in `admin/tests/browser/richeditor-display.test.ts`. Copy them from a `--reporter=json` run; do not retype them.
4. In `run_admin`, run the browser suite through `vitest_json` with those lists.
5. In `run_docs`, after `check-admin-dist.sh`, run `bash "$ROOT/scripts/check-phase10.sh" --hygiene` so cross-phase admin drift fails Phase 17's gate.
6. In `run_self_test`, add one assertion that the script itself contains that check-phase10 hygiene call.
7. Update the stage header comments for `admin` and `docs`.
**C. Reorder imports (`admin/tests/views/ReorderView.test.ts`).** Add a `describe('reorder modules')` that imports by path:
- `../../src/views/ReorderView.vue`;
- `../../src/components/reorder/ReorderBranch.vue`;
- `REORDER_PAGE` from `../../src/components/reorder/context`.
Assert behavior, not a bare import:
1. On the existing mounted reorder route (reuse the file's helpers and the `tree()` reply), `findComponent(ReorderView)` exists and `findAllComponents(ReorderBranch)` is non-empty.
2. ReorderBranch mounted standalone, with `global.provide` keyed by `REORDER_PAGE` holding a stub page:
- refs for dragging, dropTarget and blocked set to null;
- `nameOf` returning the node name;
- `canMove` returning true;
- `vi.fn` handlers.
It renders `[data-reorder-node]` for each node with its name, and clicking a move button (`[data-action]`) calls `move` with that node id. Check the `TreeNode` shape in `src/components/reorder/tree.ts`.
**D. CSP hardening (optional item from the task file, included per the orchestrator).**
1. In `modules/boardwalk/boardwalk.go:32`, set `contentSecurityPolicy` to the old value plus `; form-action 'self'; frame-src 'none'`. Update the comment at lines 29-31 and the SetSecurityHeaders doc at 165-167: no native form posts off-origin, no framed content; sandboxed srcdoc previews stay allowed because they are not fetched.
2. Update both expected CSP strings in `modules/boardwalk/boardwalk_test.go` (lines 313 and 485).
3. Update the docs:
- `modules/boardwalk/README.md`: line 30 (features) names the directives; line 58 (API table) changes "the `script-src 'self'` CSP" to name `script-src 'self'`, `form-action 'self'` and `frame-src 'none'`;
- `docs/backend/admin-spa.md:18`: list the CSP directives.
4. Re-run the srcdoc probe from `<context>` with admin's own playwright 1.60.0 from a scratch script. Do not commit the script. Confirm a `sandbox=""` srcdoc iframe still renders under the new header and an `<iframe src>` is blocked, and record the result in the SUMMARY. If srcdoc is blocked, drop only `frame-src 'none'`, keep `form-action 'self'`, and record why.
**E. Commits, by path:**
- `fix(scripts): green check-phase10 hygiene after Phase 17 and run it from check-phase17 (T-10-24, T-10-25)` — the two scripts;
- `test(admin): import the reorder modules by path` — ReorderView.test.ts;
- `fix(boardwalk): add form-action and frame-src to the admin CSP` — boardwalk.go, boardwalk_test.go, README.md, docs/backend/admin-spa.md.
</action>
<verify>
<automated>bash scripts/check-phase10.sh --hygiene && bash scripts/check-phase10.sh --self-test && bash scripts/check-phase10.sh --spa && bash scripts/check-phase17.sh --self-test && bash scripts/check-phase17.sh admin && go test ./modules/boardwalk/... -count=1 && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check</automated>
</verify>
<done>
- `--hygiene` prints `phase10 hygiene passed` and `--self-test` prints `phase10 self-test passed`, with the three new plants each refused for their own rule.
- `--spa` runs both the happy-dom suite and the browser suite.
- check-phase17's admin stage requires the browser-suite names, and its docs stage calls the Phase 10 hygiene.
- The boardwalk tests assert the new CSP, and the README and docs match.
- The srcdoc probe result is recorded.
</done>
</task>
<task type="auto">
<name>Task 3: Rebuild and commit the admin dist, run every gate, refresh 10-SECURITY-REVIEW.md with removal checks</name>
<files>modules/boardwalk/dist, .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md</files>
<action>
**A. Rebuild the dist.**
1. Run `npm --prefix admin run build`.
2. Stage with `git add --all -- modules/boardwalk/dist` and commit as `build(quick-261012-s3g): rebuild admin dist with the DOMPurify display sanitizer`.
3. Then run `bash scripts/check-admin-dist.sh` and `bash scripts/check-phase10.sh --hygiene`. If the dist-origin rule reports any literal other than the allow-listed ones, inspect where it occurs in the minified JS. Stop and report rather than allow-listing a URL the code actually loads.
**B. Gates, in this order.** Use `sudo -u golem bash -c '...'` for Docker-backed runs if the socket is denied.
1. `go vet ./...`
2. `go test ./...`
3. `npm --prefix admin run typecheck`
4. `npm --prefix admin test`
5. `npm --prefix admin run test:browser`
6. `bash scripts/check-admin-dist.sh`
7. `bash scripts/check-phase10.sh --all`
8. `bash scripts/check-phase17.sh` (default `--all`). If no Docker daemon answers, it prints NOT RUN postgres; then run `bash scripts/check-phase17.sh postgres` on the Docker host via sudo.
A failure caused by this plan's changes is fixed here. A failure that also fails on a clean checkout of HEAD before Task 1 (for example in a sibling repository) goes to `.planning/quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/deferred-items.md` with the command and the output tail, and is named in the SUMMARY. Do not mark the review green while any gate is red.
**C. Removal checks.** For each mutation: apply it, run the command, record the exit code, restore the file, and confirm `git status --short` shows only the pre-existing foreign edits.
| Threat | Mutation | Command | Expected exit |
|--------|----------|---------|---------------|
| T-10-16 (a) | Restore the pre-fix html.ts from the commit before Task 1's code commit | `npm --prefix admin run test:browser` | 1 |
| T-10-16 (b) | Make sanitizerUsable return true unconditionally | `npx --prefix admin vitest run tests/form/RichEditorField.test.ts` (happy-dom) | 1, because DOMPurify keeps the script under happy-dom |
| T-10-16 (c) | Remove the uponSanitizeAttribute hook | `npm --prefix admin run test:browser` | 1, because the ftp: and sms: hrefs or the href on p survive |
| T-10-04 | Drop `form-action 'self'` from the CSP | `go test ./modules/boardwalk -run 'TestSecurityHeadersOnEveryResponse|TestPhase101BoardwalkExports' -count=1` | 1 |
| T-10-24 | — | `bash scripts/check-phase10.sh --self-test`, with the new plants | each plant refused |
| T-10-SC | Change the dompurify pin in a scratch copy of admin/package.json | `npm ci --dry-run --offline` against the committed lockfile | 1 |
**D. Refresh `.planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md`.**
1. Set the frontmatter to `reviewed: "2026-10-12"`. Keep `threats_open: 0` only if every gate in B passed.
2. Add a short paragraph under the H1: the 2026-10-12 refresh re-reviews the admin surface after Phases 12.1-17 (17-02 added the read-only richeditor sink), and names the DOMPurify fix, the browser suite and the gate repair.
3. Rewrite these rows, keeping the columns, the `| T-10-NN ` row prefix and a named test, gate or `tests/` path in every high mitigate row (`run_evidence` checks this at check-phase10.sh:434-441):
- **T-10-04:** the new CSP directives.
- **T-10-16:** the production mitigation is now Vue text interpolation everywhere except two reviewed sinks: MarkdownField (server-rendered) and the RichEditorField read-only view through DOMPurify displayHtml with the canary. Tests: `tests/browser/richeditor-display.test.ts`, `tests/form/RichEditorField.test.ts`, `--hygiene` with line-exact T-17-10 exceptions, `--self-test` plants; stages `--spa`, `--hygiene`. Residual: the CSP still allows inline styles and off-origin images in rendered content.
- **T-10-22:** also names listState.ts (T-16.1-03).
- **T-10-24:** green on 2026-10-12, plus the check-phase17 cross-phase hygiene call.
- **T-10-25:** the two allow-listed literals and the reorder path imports.
- **T-10-SC:** dompurify 3.4.16, @vitest/browser 3.2.7 and playwright 1.60.0 added under the decision note, with exact pins, the committed lockfile and `npm ci`.
4. Add the removal-check rows from C to the Removal check table, keeping the existing rows.
5. Update Residual risk with:
- stored richeditor HTML is still not sanitized on read server-side;
- the display relies on DOMPurify plus the CSP;
- the 3.4.17 bump is pending after its cooldown.
6. Run `bash scripts/check-phase10.sh --evidence`. It must pass; it also re-runs the security, postgres and openapi stages.
7. Commit only this file as `docs(10): refresh security review evidence after the DOMPurify fix (T-10-16, T-10-24)`.
Do not edit 10-SECURITY.md, STATE.md or anything under `.planning/phases/17-blog-companions/`; the orchestrator owns them.
</action>
<verify>
<automated>bash scripts/check-admin-dist.sh && bash scripts/check-phase10.sh --all && bash scripts/check-phase17.sh && go vet ./... && go test ./... && npm --prefix admin test && npm --prefix admin run test:browser && npm --prefix admin run typecheck && bash -c 'grep -q "reviewed: \"2026-10-12\"" .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md && grep -q "tests/browser/richeditor-display.test.ts" .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md'</automated>
</verify>
<done>
- The dist is rebuilt and committed in its own build commit.
- Every listed gate exits 0 (or a pre-existing foreign failure is logged in deferred-items.md and named in the SUMMARY).
- The removal checks are recorded with their exit codes, and the tree is restored.
- 10-SECURITY-REVIEW.md is refreshed (2026-10-12), passes `--evidence`, and is committed as a planning-docs commit.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Stored richeditor HTML → admin DOM | Legacy, imported or pre-gate values, never sanitized on read, rendered in the admin origin by the read-only/preview view |
| npm registry → admin build | New packages (dompurify at runtime; @vitest/browser and playwright for tests) enter the lockfile and the committed dist |
| Admin HTML responses → browser | CSP decides what injected markup can do: submit forms, frame content, run script |
| Gate scripts → phase acceptance | Hygiene exceptions and allow-lists decide what counts as clean |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-s3g-01 | Tampering | `displayHtml` (DOM clobbering via form named controls) | high | mitigate | DOMPurify dedicated instance with the EDITOR_TAGS/href-only config. The canary fails closed. The real-Chromium suite covers every reported vector; removal check (a) proves it. |
| T-s3g-02 | Tampering | `displayHtml` in a DOM without a faithful walker (happy-dom, a future runtime) | medium | mitigate | `sanitizerUsable()` canary plus `isSupported`, returning '' otherwise. Removal check (b) proves the happy-dom test catches its removal. |
| T-s3g-03 | Information Disclosure | Requests started while sanitizing (img src, tracking) | medium | mitigate | String-mode DOMPurify parses with DOMParser into an inert document; the live-holder adoption is removed. The resource-timing test has a positive control. |
| T-s3g-04 | Tampering | Links outside the editor allowlist (javascript:, ftp:, sms:, href on non-a) | medium | mitigate | `uponSanitizeAttribute` hook with `isAllowedLink` plus DOMPurify's URI check. Removal check (c). |
| T-s3g-05 | Spoofing | Phishing form or frame overlay injected into the admin | medium | mitigate | CSP `form-action 'self'; frame-src 'none'` (boardwalk tests assert it). Probe-verified not to break sandboxed srcdoc previews. |
| T-s3g-06 | Repudiation | Gate exceptions too broad, hiding a new sink or origin | medium | mitigate | Line-exact, path-bound, commented exceptions, plus self-test plants: path-moved display line, template parse, foreign origin in dist. |
| T-s3g-SC | Tampering | npm installs (dompurify, @vitest/browser, playwright) | high | mitigate | All three were [VERIFIED] at planning (maintainers, repo, release date) and are re-checked by the executor before install. Exact pins, the dompurify integrity matched in the lockfile, `npm audit signatures`, `npm ci`, decision note, 7-day cooldown version choice. |
</threat_model>
<verification>
- `npm --prefix admin run test:browser` is RED on the pre-fix html.ts and GREEN after the fix.
- `npm --prefix admin test` and `npm --prefix admin run typecheck` pass.
- These all exit 0:
- `bash scripts/check-phase10.sh --hygiene`
- `bash scripts/check-phase10.sh --self-test`
- `bash scripts/check-phase10.sh --all`
- `bash scripts/check-phase17.sh` (postgres on a Docker host)
- `bash scripts/check-admin-dist.sh`
- `go vet ./...` and `go test ./...`
- `go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check`
- The removal checks in Task 3 are recorded in 10-SECURITY-REVIEW.md.
- `git log` shows planning-docs commits (decision note + STACK.md; security review) separate from code commits (admin fix; scripts; reorder test; boardwalk CSP; dist build), none with a Co-Authored-By trailer.
</verification>
<success_criteria>
- T-10-16 / T-17-10: the clobbering bypass is closed by DOMPurify, proven in a real browser engine, with a fail-closed fallback and a CSP that blocks off-origin form posts and framed content.
- T-10-24: `check-phase10.sh --all` is green again, and check-phase17 runs the Phase 10 hygiene so the drift cannot recur silently.
- T-10-25: the two literal origins are allow-listed with reasons, and the reorder modules are imported by path in a behavior test.
- The embedded dist matches the sources; the decision note, STACK.md and 10-SECURITY-REVIEW.md record the change.
</success_criteria>
## Source coverage audit
| Source item (temp/sec_phase_10.md) | Covered by |
|------------------------------------|-----------|
| 1. Replace clean() with DOMPurify: ALLOWED_TAGS = EDITOR_TAGS, ALLOWED_ATTR ['href'], uponSanitizeAttribute + isAllowedLink, keep DROPPED-with-content | Task 1 C-D |
| 1. Decision note in .planning/notes/ | Task 1 A |
| 1. Add to .planning/research/STACK.md | Task 1 A |
| 1. Exact pin, legitimacy check, commit lockfile, npm ci | Task 1 A-B (planning-time [VERIFIED] table plus executor re-check) |
| 1. Sanitize into an inert document; no adoption into the live document (html.ts:102) | Task 1 D, inert-document test |
| 1. Real-browser regression test (Vitest browser mode + Playwright Chromium) covering every listed vector | Task 1 C (behavior list) |
| 1. Optional CSP `form-action 'self'; frame-src 'none'` + header tests, README, docs | Task 2 D |
| 2. Line-exact commented T-17-10 exceptions for the remaining raw-HTML lines | Task 2 A.1 |
| 2. Allow-list https://prosemirror.net and https://admin.invalid | Task 2 A.2 |
| 2. Import reorder context.ts, ReorderBranch.vue, ReorderView.vue by path in a test | Task 2 C |
| 2. check-phase17.sh runs check-phase10.sh --hygiene | Task 2 B |
| 3. Rebuild admin, commit dist, pass check-admin-dist, check-phase10 --all, check-phase17, go vet/test, npm test/typecheck | Task 3 A-B |
| 4. Refresh 10-SECURITY-REVIEW.md (new date, removal check for the clobbering vector) | Task 3 C-D |
| 4. g15office comment + move u44mzmlf to Testing; re-run /msd-secure-phase 10 and Phase 17's security check | EXCLUDED: the orchestrator does these after execution |
| Code and planning docs in separate commits; stage by path | Every task's commit steps |
<output>
Create `.planning/quick/261012-s3g-fix-phase-10-security-blockers-t-10-16-d/261012-s3g-SUMMARY.md` when done. It must include:
- the RED test names from Task 1;
- the srcdoc probe result;
- the gate results;
- the removal-check exit codes;
- any deferred items.
</output>

View File

@@ -0,0 +1,174 @@
---
phase: quick-261012-s3g
plan: 01
subsystem: admin-spa, boardwalk, gate scripts
tags: [security, xss, dom-clobbering, dompurify, csp, vitest-browser, playwright, T-10-16, T-17-10, T-10-24, T-10-25]
status: incomplete
status_reason: "Everything in the plan is done and committed. One listed gate is still red: check-phase10.sh --all (and --security, --evidence) fail only on TestPhase09SecurityRoutes in ../fonoteka.go. That failure predates this plan (stale phase09AdminRoutes allowlist after Phase 17-01) and is logged in deferred-items.md. The review keeps T-10-24 open (threats_open: 1) until it is fixed."
requires:
- Phase 17 richeditor field (17-02 read-only display sink)
- Phase 10 gate (scripts/check-phase10.sh)
provides:
- DOMPurify-backed displayHtml with a fail-closed canary
- Vitest browser-mode suite (npm --prefix admin run test:browser)
- admin CSP with form-action 'self' and frame-src 'none'
- green check-phase10 --hygiene / --self-test; check-phase17 runs Phase 10 hygiene
affects:
- modules/boardwalk/dist (rebuilt)
- .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
tech-stack:
added: [dompurify 3.4.16, "@vitest/browser 3.2.7 (dev)", "playwright 1.60.0 (dev)"]
patterns: [dedicated DOMPurify instance with a per-instance hook, canary self-check that fails closed, real-engine tests in Vitest browser mode]
key-files:
created:
- .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
- admin/vitest.browser.config.ts
- admin/tests/browser/richeditor-display.test.ts
modified:
- .planning/research/STACK.md
- admin/package.json
- admin/package-lock.json
- admin/vitest.config.ts
- admin/src/components/form/richeditor/html.ts
- admin/tests/form/RichEditorField.test.ts
- admin/tests/views/ReorderView.test.ts
- README.md
- CLAUDE.md
- scripts/check-phase10.sh
- scripts/check-phase17.sh
- modules/boardwalk/boardwalk.go
- modules/boardwalk/boardwalk_test.go
- modules/boardwalk/README.md
- docs/backend/admin-spa.md
- modules/boardwalk/dist (index.html, assets/index-*.js, assets/editor-*.js)
- .planning/phases/10-admin-vue-spa/10-SECURITY-REVIEW.md
decisions:
- "The admin richeditor read-only display is sanitized by a dedicated DOMPurify 3.4.16 instance (EDITOR_TAGS, href only on <a> through isAllowedLink, DROPPED forbid-contents, string in/out). It fails closed (empty string) unless isSupported and a fixed canary hold."
- "Vitest browser mode with Playwright Chromium (@vitest/browser 3.2.7, playwright 1.60.0) runs the real-engine tests. It is not the deferred Phase 10 e2e suite (D-23)."
- "Admin CSP adds form-action 'self' and frame-src 'none'. A probe showed that sandboxed srcdoc previews still render."
- "dompurify 3.4.16 rather than 3.4.17 because of a 7-day release cooldown. Bump after 2026-10-19."
metrics:
duration: "~45 min"
completed: "2026-10-12"
estimate:
tokens: 125000
tasks: 3
actuals:
tokens: 17800
tasks: 3
commits: 7
plan_head_before: b30317b2fbbc6096e0c9ff5682de0080e721cd32
plan_head_after: 708b3ec581287756baed7962f7a29b3f95265602
commits: 7
---
# Phase quick-261012-s3g Plan 01: Phase 10 security blockers (T-10-16, T-10-24, T-10-25) Summary
The admin richeditor read-only display is now sanitized by a dedicated DOMPurify 3.4.16 instance. It parses into an inert document, keeps only EDITOR_TAGS, allows href only on `<a>` and only through `isAllowedLink`, and fails closed through a canary. A Vitest browser-mode suite in Playwright Chromium proves it against the DOM-clobbering vectors. The admin CSP gains `form-action 'self'; frame-src 'none'`. `check-phase10.sh --hygiene` and `--self-test` are green again, and `check-phase17.sh` now runs the Phase 10 hygiene.
## Commits
| Task | Commit | Message |
|------|--------|---------|
| 1A | fba15c9 | docs(quick-261012-s3g): decision note naming DOMPurify and Vitest browser mode for the admin |
| 1 | c1d767f | fix(admin): sanitize the richeditor read-only display with DOMPurify (T-10-16) |
| 2A/B | fd46d47 | fix(scripts): green check-phase10 hygiene after Phase 17 and run it from check-phase17 (T-10-24, T-10-25) |
| 2C | 71bd614 | test(admin): import the reorder modules by path |
| 2D | 2343788 | fix(boardwalk): add form-action and frame-src to the admin CSP |
| 3A | f269d88 | build(quick-261012-s3g): rebuild admin dist with the DOMPurify display sanitizer |
| 3D | 708b3ec | docs(10): refresh security review evidence after the DOMPurify fix (T-10-16, T-10-24) |
No commit carries a Co-Authored-By trailer. Commits were made on `master`, following the project's practice.
## Package legitimacy (re-checked before install)
The results matched the planning-time table exactly:
- **dompurify 3.4.16:** integrity `sha512-sqo+pNp3…MRnQ==` (equal in the lockfile); sole maintainer cure53; repository `github.com/cure53/DOMPurify`; license MPL-2.0 OR Apache-2.0; released 2026-09-23.
- **@vitest/browser 3.2.7:** vitest-dev maintainers; peer `vitest: 3.2.7`; released 2026-07-06.
- **playwright 1.60.0:** Microsoft maintainers; one dependency, playwright-core 1.60.0; released 2026-05-11.
`npm --prefix admin audit signatures` reported 308 packages with verified registry signatures and 132 with verified attestations. The Chromium build was already on disk (`chromium-1223`), so no `playwright install` was needed. `package.json` gained exactly the three caret-free pins and the `test:browser` script.
## TDD: RED, then GREEN
**RED.** The new suite ran against the pre-fix `html.ts` (hand-built `clean()`): 4 failed and 3 passed.
- `richeditor display in a real browser engine > drops clobbered forms with everything inside them`: the form with `<input name="nodeType">` was kept whole, including `img[onerror]`.
- `richeditor display in a real browser engine > keeps a form control named localName from breaking the view`: `TypeError: element.localName.toLowerCase is not a function`.
- `richeditor display in a real browser engine > parses into an inert document, so nothing loads before the value is bound`: the probe image inside the clobbered form was requested.
- `RichEditorField > renders read-only values exactly through the display policy`: the form, input and img reached the read-only box.
**GREEN.** With the DOMPurify `html.ts`, the suite passes 7/7.
## CSP srcdoc probe (Task 2D)
The probe was a scratch Node script (not committed) using admin's playwright 1.60.0 and headless Chromium 1223. It served a page with the new header: `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none'`.
- **Sandboxed srcdoc iframe (`sandbox=""`):** it rendered (body text `preview`), and its same-origin image `/img-srcdoc.png` was requested.
- **`<iframe src="/framed">`:** blocked. The console reported `Framing '…/framed' violates … "frame-src 'none'"`, and the server got no request.
- **Native form post to another origin (password input):** blocked. The console reported `Sending form data to '…/steal' violates … "form-action 'self'"`, and the other server got no request.
`frame-src 'none'` was kept.
## Gate results
| Gate | Result |
|------|--------|
| `bash scripts/check-admin-dist.sh` | PASS ("matches a fresh build") |
| `bash scripts/check-phase10.sh --all` | **FAIL (exit 1).** `--self-test` passes, then `--go` refuses on `TestPhase09SecurityRoutes` in `../fonoteka.go` (pre-existing, see Deferred Issues) |
| `check-phase10.sh --self-test` / `--hygiene` / `--spa` / `--postgres` / `--openapi` / `--dist` (run individually) | PASS (each exit 0) |
| `check-phase10.sh --security` / `--evidence` | FAIL (exit 1), same single fonoteka.go test. `--evidence` prints "phase10 evidence files passed" before failing |
| `bash scripts/check-phase17.sh` (`--all`, postgres included, Docker reachable) | PASS ("Phase 17 gate passed"; the docs stage printed "phase10 hygiene passed"; the admin stage ran 1326 happy-dom tests and 7 browser tests) |
| `check-phase17.sh --self-test` / `admin` | PASS |
| `go vet ./...` | PASS |
| `go test ./...` (Docker) | PASS (40 packages ok) |
| `npm --prefix admin test` (happy-dom) | PASS (88 files, 1326 tests) |
| `npm --prefix admin run typecheck` | PASS |
| `npm --prefix admin run test:browser` | PASS (1 file, 7 tests) |
| `go test ./cmd/summer -run TestDocsTree`, `summer docs:build --check` | PASS ("no problems found") |
| `go test ./modules/boardwalk/...` | PASS |
## Removal checks (Task 3C)
After each check the file was restored, and `git status --short` showed only the pre-existing foreign entries.
| Threat | Mutation | Command | Exit |
|--------|----------|---------|------|
| T-10-16 (a) | pre-fix `html.ts` from fba15c9 | `npm --prefix admin run test:browser` | 1 (4 failed: clobbered forms, localName, inert document, read-only mount) |
| T-10-16 (b) | `sanitizerUsable` returns true | `npx vitest run tests/form/RichEditorField.test.ts` (happy-dom) | 1 (the script was kept in the box, RichEditorField.test.ts:167) |
| T-10-16 (c) | `uponSanitizeAttribute` hook removed | `npm --prefix admin run test:browser` | 1 (`p[href=/on-p]` plus the ftp:/sms: hrefs survive) |
| T-10-04 | `form-action 'self'` dropped | `go test ./modules/boardwalk -run 'TestSecurityHeadersOnEveryResponse\|TestPhase101BoardwalkExports' -count=1` | 1 (both fail) |
| T-10-24 | the vhtml-elsewhere, template-parse and origin plants | `bash scripts/check-phase10.sh --self-test` | 0, with each plant refused for its own rule |
| T-10-SC | dompurify pin set to 3.4.17 in a scratch copy | `npm ci --dry-run --offline` | 1 ("lock file's dompurify@3.4.16 does not satisfy dompurify@3.4.17") |
## Deviations from Plan
### Auto-fixed Issues
None. The plan executed as written, with two small additions:
- **Lazy instance creation (Rule 2).** The dedicated DOMPurify instance is created lazily on first use and cached at module level, rather than at import time. If `window` is absent, it fails closed instead of throwing at import. The hook is still per-instance, never global.
- **Extra clobbering vector.** The browser suite also covers `form/button name=nodeType` with an img onerror, on top of the listed vectors.
### Review state differs from the plan's happy path
Task 3D says to keep `threats_open: 0` only if every gate passed. Because `--all` is red on the fonoteka.go failure, the refreshed `10-SECURITY-REVIEW.md` records `threats_open: 1`, with T-10-24 marked `open`; it gives the evidence and the exact fix. T-10-16 is mitigated. `--evidence` cannot exit 0 until fonoteka.go is fixed, because it re-runs `--security`, which runs `check-phase9.sh --security`.
## Deferred Issues
See `deferred-items.md` in this directory (left uncommitted for the orchestrator).
- **The issue:** `../fonoteka.go` `TestPhase09SecurityRoutes` fails because the app's `phase09AdminRoutes` allowlist lacks the Phase 17-01 cabana routes (`GET/POST …/{controller}/reorder`, `GET …/{controller}/singleton`). It was already recorded in 09-SECURITY.md.
- **Proof it predates this plan:** it fails identically with the pre-plan framework `boardwalk.go` (go test -overlay), and boardwalk.go is the only non-test Go file this plan changed.
- **Fix:** a 3-key addition in the sibling repository fonoteka.go. Then re-run `check-phase10.sh --all`, set `threats_open: 0` and re-run `/msd-secure-phase 10`.
## Known Stubs
None.
## Threat Flags
None. The plan adds no new network endpoint, auth path or schema change; the CSP only gets stricter.
## Self-Check: PASSED
- Created files exist: `.planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md`, `admin/vitest.browser.config.ts` and `admin/tests/browser/richeditor-display.test.ts`.
- All 7 commit hashes are present in `git log`.
- `html.ts` contains no raw-HTML token and no `clean()`.
- The pins and the dompurify integrity are in the committed `package.json` and lockfile.

View File

@@ -0,0 +1,30 @@
# Deferred items: quick 261012-s3g
## 1. fonoteka.go `TestPhase09SecurityRoutes` keeps `check-phase10.sh --all` red
**Gate:** `bash scripts/check-phase10.sh --all` (stage `--go`), also `--security` and `--evidence` (both run `check-phase9.sh --security`).
**Repository:** `../fonoteka.go` (sibling consumer app), not this plan's code.
**Command and output tail:**
```
$ cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run 'TestPhase09SecurityRoutes$' -count=1
--- FAIL: TestPhase09SecurityRoutes (0.75s)
admin_phase09_security_test.go:52: unexpected admin routes: map[string]surf.RouteInfo{
"GET /plytadmin/api/v1/{vendor}/{plugin}/{controller}/reorder": {... PluginID:"summercms.cabana", Middleware:[]string{"backend"}, Raw:true},
"GET /plytadmin/api/v1/{vendor}/{plugin}/{controller}/singleton": {... Middleware:[]string{"backend"}, Raw:true},
"POST /plytadmin/api/v1/{vendor}/{plugin}/{controller}/reorder": {... Middleware:[]string{"backend"}, Raw:true}}
FAIL git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka
$ bash scripts/check-phase10.sh --all
phase10 self-test passed
refuse: failed git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka TestPhase09SecurityRoutes
refuse: go test ./... ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... in .../fonoteka.go (test=1 detect=1)
```
**Pre-existing:** yes. Phase 17-01 (`23f33c3`) added the three cabana routes; fonoteka.go's `phase09AdminRoutes` allowlist was never updated. Already recorded as a follow-up in `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-SECURITY.md` (line 83). Re-run with the framework's pre-plan `modules/boardwalk/boardwalk.go` (b30317b, via `go test -overlay`; it is the only Go file this plan changed outside tests) fails identically.
It is the only failing test in the fonoteka.go run. Every other `--all` stage was run on its own and passes: `--self-test`, `--postgres`, `--spa`, `--openapi`, `--dist`, `--hygiene`. The evidence-file check in `--evidence` passes too.
**Fix (out of scope here; a separate fonoteka.go commit):** add the three route keys (`GET …/{controller}/reorder`, `POST …/{controller}/reorder`, `GET …/{controller}/singleton`, each `public: false`) to `phase09AdminRoutes` in `fonoteka.go`'s `admin_phase09_security_test.go`. Then re-run `bash scripts/check-phase10.sh --all`, close T-10-24 in `10-SECURITY-REVIEW.md` (`threats_open: 0`) and re-run `/msd-secure-phase 10`.