Skip to content

fix(security): close M-3 — pre-sanitize Chronicle HTML at journal ingress (defense-in-depth)#57

Merged
keyxmakerx merged 3 commits into
mainfrom
claude/fm-sec-chunk-3
May 25, 2026
Merged

fix(security): close M-3 — pre-sanitize Chronicle HTML at journal ingress (defense-in-depth)#57
keyxmakerx merged 3 commits into
mainfrom
claude/fm-sec-chunk-3

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Cites: 2026-05-21-core-tenets §T-B1; reports/foundry/2026-05-22-fm-security-audit.md §2 M-3, §4 Chunk 3, §0.5 D1=(c); cross-reference reports/chronicle/2026-05-22-c-security-audit.md §1.3 + §0.5; dispatches/foundry/FM-SEC-CHUNK-3.md
Security implication: closes M-3 as defense-in-depth. Chronicle already sanitizes EntryHTML server-side via bluemonday UGCPolicy at the service layer (8 plugins, verified in C-SECURITY-AUDIT §1.3). This PR adds a Foundry-side ingress sanitization layer via TextEditor.cleanHTML so a regression on Chronicle's server (or an old DB entry that predates a sanitization-rule strengthening) doesn't survive the trip into a Foundry JournalEntry page.
Consumer-verified: Chronicle is the primary mitigation; this is the middle layer in a three-layer defense (Chronicle server-side → Foundry ingress → Foundry render-time). Confirmed via grep of Chronicle audit's §1.3 service-layer sanitize call inventory.
Foundry compatibility: v12 / v13 / v14 — helper probes foundry.applications.ux.TextEditor.cleanHTML (v13+) first, falls back to the v12 global TextEditor.cleanHTML. Both are available by the time SyncManager.start runs on ready. Fail-open if neither is found (with console warn) so the module never blocks ingest.
Mockup: n/a — visually identical for legitimate content. Malicious shapes are stripped to inert HTML by Foundry's cleanHTML; the JournalEntry page renders without the attacker payload.

What this changes

File Change
scripts/_html-sanitizer.mjs (NEW, 100 lines) Pure helper exporting _sanitizeIncomingHTML(html). Probes v13/v12 cleanHTML namespaces, gates on skipIncomingSanitization setting, fail-open with warn if cleanHTML unavailable or throws. Non-string input coerced to ''.
scripts/settings.mjs Registers skipIncomingSanitization world setting (Boolean, default false = sanitization ON). Operator-config escape per dispatch — flip ON only for high-trust deployments where Foundry's cleanHTML strips legitimate inline styling Chronicle ships intentionally.
lang/en.json CHRONICLE.Settings.SkipIncomingSanitization.{Name,Hint} for the new setting.
scripts/journal-sync.mjs Wires _sanitizeIncomingHTML at 4 ingestion sites: _onEntityUpdated → _syncPagesToJournal, _onEntityUpdated → _syncPlayerNotesPage, _createJournalFromEntity → _splitByHeadings, _createJournalFromEntity → Player Notes text.content.
scripts/note-sync.mjs Wires _sanitizeIncomingHTML at 2 ingestion sites: _createJournalFromNote content, _updateJournalFromNote content.
tools/test-html-sanitizer.mjs (NEW, 290 lines) 20 new tests: behavioral (delegates to cleanHTML, v13/v12 probe order, skip flag, fail-open paths, non-string coercion, delegates malicious shapes intact) + static-source pins that every entity.entry_html / note.entry_html ingestion site is wrapped by _sanitizeIncomingHTML (catches future regressions).

Why

Foundry stores Chronicle's entry_html directly into JournalEntry page text.content. Foundry's TextEditor.enrichHTML sanitizes on RENDER — robust but not airtight (escape-then-render bugs in Foundry's renderer have happened before, and a future Foundry change that loosens cleanHTML's rules would reach back into already-stored data).

C-SECURITY-AUDIT §1.3 confirmed Chronicle already sanitizes via bluemonday UGCPolicy at the service layer (entities, calendar, timeline, sessions, notes, posts, campaigns, widgets — 8 plugins). That's the primary mitigation. This PR adds the middle layer: run the same sanitizer Foundry uses at render time, at ingress, so on-disk content reflects what Foundry considered safe at the time of sync.

shop-widget.mjs intentionally NOT wired — the audit verified the description field is a plain-text value not rendered as HTML in the shop template, so there's no XSS surface there.

Test plan

  • node --test tools/test-*.mjs passes locally (321 baseline + 20 new = 341/341)
  • Static-source pins lock every entity.entry_html / entity.player_notes_html / note.entry_html ingestion site to pass through _sanitizeIncomingHTML (CI catches a future regression)
  • Behavioral coverage: happy path, v13/v12 namespace probe order, skip-setting bypass, fail-open when cleanHTML missing, fail-open when cleanHTML throws, non-string input coercion to '', delegates malicious shapes (<script>, javascript: URL, <iframe>) to cleanHTML intact
  • Manual verification in Foundry: launch a world with a configured Chronicle apiUrl; sync an entity with legitimate HTML content (<h1>, <p>, <strong>); verify the JournalEntry page renders identically to current behavior. Then inject a malicious entry_html (e.g., via dev tools temporarily): <script>alert(1)</script> or &lt;img src=x onerror=...&gt;; sync; verify the script is stripped from the stored page content (inspect page.text.content after sync). Then flip skipIncomingSanitization ON; re-sync; verify the layer is bypassed.

Tenet self-check

  • T-B1 security: closes M-3 as defense-in-depth; fail-closed on the operator-action surface (default sanitization ON), fail-open on the runtime degradation surface (missing cleanHTML still ingests but warns)
  • T-B2 plugin isolation: changes stay within chronicle-foundry-module
  • T-B3 production UI: silent on the happy path; operator-config escape exposed in the standard module-settings dialog; warnings go to the browser console (the standard diagnostic surface)
  • T-B4 dual-audience docs: helper file preamble explains the three-layer defense, per-callsite comments explain the wrap, setting comment explains the default-off rationale
  • T-O4 single source of truth: all 6 ingestion sites share one validator; static-source tests scan source for any entity.entry_html / note.entry_html reference and assert it's wrapped — a new ingestion path that bypasses the sanitizer fails CI

Stop-and-flag (per dispatch)

None of the three triggers fired:

  • TextEditor.cleanHTML available at module ingest? YES — SyncManager.start runs on the ready hook, well after Foundry's init. Both v12 (globalThis.TextEditor) and v13+ (foundry.applications.ux.TextEditor) namespaces are populated by then. Helper probes both for robustness.
  • cleanHTML strips legitimate inline styling? Operator-config escape (skipIncomingSanitization) covers this case. Documented in the helper preamble and the setting's Hint.
  • shop-widget.mjs ingests HTML? NO — verified the description field is a plain-text value (template doesn't {{{ triple-stash it). Skipped per dispatch.

Drift from dispatch

None of substance. Three notes:

  1. shop-widget.mjs confirmed skipped. Audit step: grepped templates/shop-window.hbs for description / {{{ — no HTML rendering of the field. Plain-text only.
  2. 6 ingestion sites total (dispatch said "3 sites + note-sync + maybe shop"). Actual breakdown: journal-sync 4 (entry_html + player_notes_html, each in both create + update paths), note-sync 2 (create + update). All wired through one shared helper.
  3. Cordinator decision doc decisions/2026-05-23-decision-routing.md not present in this branch's view of the cordinator repo (the relay packet referenced it). Did not block this PR — the dispatch is self-contained. Surfacing here for cordinator awareness.

Note on commit history

Three commits via MCP push_files (signing infra failure on chronicle-foundry-module — 10th confirmed reproduction). The split is driven by file-size budget on each push, not logical commits — squash-on-merge will collapse cleanly:

  1. c91a7ce — Add _html-sanitizer.mjs + tests (CI green standalone since static-source pins haven't run yet against the wired call sites; behavioral tests pass).
  2. a95c1aa — Wire into note-sync.mjs + register setting + lang keys.
  3. 085f4b9 — Wire into journal-sync.mjs.

🤖 Cites dispatches/foundry/FM-SEC-CHUNK-3.md. Generated by Claude Code.


Generated by Claude Code

…3 part 1)

Defense-in-depth HTML sanitization wrapper around Foundry's TextEditor.cleanHTML
for Chronicle-supplied content at journal/note ingress. M-3 from FM-SECURITY-AUDIT
§2 + §4 Chunk 3 + §0.5 D1=(c). Cross-references C-SECURITY-AUDIT §1.3 which
confirmed Chronicle already sanitizes server-side via bluemonday UGCPolicy
(8 plugins) — this is the middle layer between Chronicle's server-side and
Foundry's render-time defenses.

20 new tests: delegates-to-cleanHTML, v13/v12 namespace probe, operator-config
escape (skipIncomingSanitization), fail-open when cleanHTML missing/throws,
non-string input coercion, delegates malicious shapes, static-source pins
on all 6 ingestion sites + settings + lang.

Cites 2026-05-21-core-tenets §T-B1.
…etting (FM-SEC-CHUNK-3 part 2)

- settings.mjs: register skipIncomingSanitization world setting (default
  false = sanitization ON). Operator-config escape per dispatch.
- lang/en.json: Name + Hint for the new setting.
- note-sync.mjs: wrap note.entry_html at both ingestion sites
  (_createJournalFromNote, _updateJournalFromNote).

Per FM-SECURITY-AUDIT §2 M-3, §4 Chunk 3, §0.5 D1=(c).
…EC-CHUNK-3 part 3)

Four ingestion sites in journal-sync.mjs now pass Chronicle-supplied HTML
through Foundry's TextEditor.cleanHTML at ingress:

- _onEntityUpdated → _syncPagesToJournal callsite (entry_html, update path)
- _onEntityUpdated → _syncPlayerNotesPage callsite (player_notes_html, update path)
- _createJournalFromEntity → _splitByHeadings callsite (entry_html, create path)
- _createJournalFromEntity → Player Notes text.content (player_notes_html, create path)

shop-widget.mjs intentionally NOT wired — audit verified the description
field is plain-text in the template, not HTML.

Per FM-SECURITY-AUDIT §2 M-3, §4 Chunk 3, §0.5 D1=(c).
@keyxmakerx keyxmakerx merged commit c93c6d1 into main May 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant