Covers Critical (stored-XSS escaping cluster) + High (copy/paste id reuse, header/footer save corruption, AI-boundary validation) findings from the 2026-07-12 audit. All claims verified against code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.5 KiB
Site Builder — security & data-loss hardening (2026-07-12)
Fixes the Critical (stored-XSS / HTML-breakout) and High (state-corruption
and save data-loss) findings from the 2026-07-12 Fable audit of the Craft.js site
builder (/workspace/site-builder/craft/). Medium/Low findings are deferred to the
audit backlog (in memory), not this spec. Sequenced before the asset-picker
work per user decision. Every claim below was verified against the code before
writing this spec.
1 — Escaping cluster (Critical, do first: closes the most with one change)
Verified root cause: escapeHtml exists once (utils/html-export.ts:201) but is
not exported and no component uses it; there are 27 local esc/escapeHtml
definitions across src/, in three incompatible variants (some omit &, some omit
quotes entirely, none escape '), and several land in attribute or JS-string
contexts where HTML-entity escaping is wrong anyway.
1a — Shared escaping/URL util
New craft/src/utils/escape.ts (exported, single source of truth):
export function escapeHtml(s: string): string; // & < > " — text/content context
export function escapeAttr(s: string): string; // escapeHtml + ' — attribute context
export function safeUrl(s: string): string; // returns '' (or '#') for javascript:/data:text/html/vbscript: schemes
- Move the correct body from
html-export.tsintoescape.ts;html-export.tsimports it. Delete all 27 local copies and import fromescape.ts. safeUrlwraps every exportedhref/src/action/url()value:ButtonLink,Icon,SocialLinks,Logo,Menu,Navbar,_cta-helpers,ContentSlider,FeaturesGrid,PricingTable,ImageBlock,VideoBlock,Gallery, background images. Attribute values useescapeAttr; text nodes useescapeHtml.
1b — JS-string / raw-HTML contexts (entity escaping is NOT enough here)
- HtmlBlock (
basic/HtmlBlock.tsx:144):toHtmlreturnsprops.coderaw while the render sanitizes via the file's ownpurifyHtml. Fix:return { html: purifyHtml(props.code || '') }. (DOMPurify already a dep.) - Countdown (
sections/Countdown.tsx:298):new Date("${targetDate}")inside an inline<script>. Fix: validate/^\d{4}-\d{2}-\d{2}(T…)?$/and/or emit viaJSON.stringify(targetDate); reject/blank otherwise. - Gallery (
sections/Gallery.tsx:296):onclick="${galleryId}_open('${esc(img.src)}')"—escdoesn't escape'. Fix: emitdata-src="${escapeAttr(img.src)}"and read it viagetAttributein the lightbox handler (no inline string interpolation).
1c — cssPropsToString (utils/style-helpers.ts)
Does zero escaping, so free-text style props (e.g. Section "Background Gradient",
background-image url()) can break out of style="…". Escape values destined for
style (strip "/; appropriately, safeUrl for url() contents).
2 — Copy / Paste / Duplicate reuse node IDs (High, state corruption)
Verified: ContextMenu.duplicate (:65) computes a fresh clone
(parseSerializedNode(...).toNode(), :75) then discards it and calls
actions.addNodeTree(freshTree, parentId) with the original tree — reusing original
ids. ContextMenu.pasteNode (:97) and useKeyboardShortcuts duplicate (:76)
both toNodeTree() → addNodeTree with original ids. Duplicating/pasting while the
source still exists yields duplicate node ids → corrupted editor state.
Fix: one shared helper that regenerates ids for a whole tree before insertion —
use Craft's query.parseFreshNode per node, or clone the NodeTree remapping every
id (and nodes/parent/linkedNodes refs) to fresh ids. Apply in all three call
sites. Also: pasteNode targets the right-clicked node as parent (:97) — pasting
onto a leaf throws; insert as a sibling (use the target's parent, fall back to ROOT).
3 — Header/Footer save corruption (High, silent data loss)
Verified: useWhpApi.save (hooks/useWhpApi.ts:12) serializes the live canvas
(query.serialize()) into craft_state and into whichever page has
id === activePageId, while header_craft_state/footer_craft_state and
header_html/footer_html are exported from the stale headerPage.craftState/
footerPage.craftState (:32,:44,:102-103). When editing the header/footer,
activePageId is __header__/__footer__ — matching no page — so live header/footer
edits are written into top-level craft_state/html (mislabeled) and the real
header/footer slots stay stale. Auto-save fires every 30s (TopBar.tsx) regardless
of edit mode → silent loss.
Fix: before serializing, commit the live canvas into the active zone's state
(PageContext's existing save-current-state path — confirm exact name during
implementation), then branch in save() on isEditingHeader/isEditingFooter:
route the live serialization to header_craft_state/footer_craft_state (and
header_html/footer_html) rather than the top-level page slots, and skip
mis-assigning it to a non-existent active page.
4 — AI-boundary validation (High)
utils/apply-ai-response.ts deserializes AI output into craft state with no
guards: buildNodeTree reads node.props.node_id/node.type.resolvedName
unchecked (:32,:37,:75,:81); update_props does blind Object.assign(p, op.props)
(:247) — can overwrite node_id/style; AI-supplied node_id is used directly as
a map key (:32) with no ROOT/uniqueness check; an unknown resolvedName throws
at addNodeTree. The live PageContext.treeToState already guards (:378,:380) —
this path doesn't.
Fix: validate every node's resolvedName against componentResolver before
insertion; allowlist update_props keys per component (never node_id); ensure
generated/patched ids are unique and non-ROOT; fail soft (skip the op + log)
rather than throwing into addNodeTree. Reuse the treeToState guard logic (ties
into backlog item "consolidate the three tree-flatteners", but only the validation
is in scope here).
Testing
Currently no tests cover toHtml escaping or the state layer. Add:
craft/src/utils/escape.test.ts—escapeHtml/escapeAttr/safeUrlincl.javascript:/data:text/htmlrejection and'handling.toHtmlescaping regression tests for the fixed components (HtmlBlock purifies; Countdown rejects a malicious date; Gallery usesdata-src; a"-containinghref/srcstays inside its attribute;javascript:href is neutralized).- Node-id regeneration test: duplicate/paste yields all-fresh ids (no collision with the source subtree).
- Header/footer save test: editing the header then saving writes live content to
header_craft_state/header_html, not the top-level page slot. - AI-boundary: unknown
resolvedNameand anode_id-overwritingupdate_propsare rejected without throwing. - Full
cd craft && npx vitest run+npm run buildgreen.
Sequencing / shippability
Independent, each shippable alone; recommended order = the section order above (1
first — highest risk, closes the most, and the escaping tests lock in the rest).
Ships via the standard WHP release pipeline (whp-deploy-release skill) after
implementation + review. The asset-picker spec
(2026-07-12-site-builder-asset-picker-design.md) follows this work.
Explicitly out of scope (→ audit backlog in memory)
All Medium/Low findings: dead related.settings/ui/ code, HeaderZone/FooterZone
resolver gap, slug dedupe, VideoBlock URL parsing, a11y pass, any-typing,
tree-flattener consolidation (beyond the AI-guard reuse), stale dev proxy, etc.