fix(security): close M-3 — pre-sanitize Chronicle HTML at journal ingress (defense-in-depth)#57
Merged
Merged
Conversation
…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).
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-referencereports/chronicle/2026-05-22-c-security-audit.md §1.3 + §0.5;dispatches/foundry/FM-SEC-CHUNK-3.mdSecurity implication: closes M-3 as defense-in-depth. Chronicle already sanitizes
EntryHTMLserver-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 viaTextEditor.cleanHTMLso 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 globalTextEditor.cleanHTML. Both are available by the time SyncManager.start runs onready. 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
scripts/_html-sanitizer.mjs(NEW, 100 lines)_sanitizeIncomingHTML(html). Probes v13/v12 cleanHTML namespaces, gates onskipIncomingSanitizationsetting, fail-open with warn if cleanHTML unavailable or throws. Non-string input coerced to''.scripts/settings.mjsskipIncomingSanitizationworld setting (Boolean, defaultfalse= 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.jsonCHRONICLE.Settings.SkipIncomingSanitization.{Name,Hint}for the new setting.scripts/journal-sync.mjs_sanitizeIncomingHTMLat 4 ingestion sites:_onEntityUpdated → _syncPagesToJournal,_onEntityUpdated → _syncPlayerNotesPage,_createJournalFromEntity → _splitByHeadings,_createJournalFromEntity → Player Notes text.content.scripts/note-sync.mjs_sanitizeIncomingHTMLat 2 ingestion sites:_createJournalFromNotecontent,_updateJournalFromNotecontent.tools/test-html-sanitizer.mjs(NEW, 290 lines)entity.entry_html/note.entry_htmlingestion site is wrapped by_sanitizeIncomingHTML(catches future regressions).Why
Foundry stores Chronicle's
entry_htmldirectly intoJournalEntrypagetext.content. Foundry'sTextEditor.enrichHTMLsanitizes 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.mjsintentionally NOT wired — the audit verified thedescriptionfield 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-*.mjspasses locally (321 baseline + 20 new = 341/341)entity.entry_html/entity.player_notes_html/note.entry_htmlingestion site to pass through_sanitizeIncomingHTML(CI catches a future regression)'', delegates malicious shapes (<script>,javascript:URL,<iframe>) to cleanHTML intact<h1>,<p>,<strong>); verify the JournalEntry page renders identically to current behavior. Then inject a maliciousentry_html(e.g., via dev tools temporarily):<script>alert(1)</script>or<img src=x onerror=...>; sync; verify the script is stripped from the stored page content (inspectpage.text.contentafter sync). Then flipskipIncomingSanitizationON; re-sync; verify the layer is bypassed.Tenet self-check
chronicle-foundry-moduleentity.entry_html/note.entry_htmlreference and assert it's wrapped — a new ingestion path that bypasses the sanitizer fails CIStop-and-flag (per dispatch)
None of the three triggers fired:
TextEditor.cleanHTMLavailable at module ingest? YES —SyncManager.startruns on thereadyhook, 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.cleanHTMLstrips legitimate inline styling? Operator-config escape (skipIncomingSanitization) covers this case. Documented in the helper preamble and the setting's Hint.shop-widget.mjsingests HTML? NO — verified thedescriptionfield is a plain-text value (template doesn't{{{triple-stash it). Skipped per dispatch.Drift from dispatch
None of substance. Three notes:
shop-widget.mjsconfirmed skipped. Audit step: greppedtemplates/shop-window.hbsfordescription/{{{— no HTML rendering of the field. Plain-text only.decisions/2026-05-23-decision-routing.mdnot 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 onchronicle-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: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).a95c1aa— Wire intonote-sync.mjs+ register setting + lang keys.085f4b9— Wire intojournal-sync.mjs.🤖 Cites
dispatches/foundry/FM-SEC-CHUNK-3.md. Generated by Claude Code.Generated by Claude Code