size, low, high, and optimum were missing from PURIFY_CONFIG.ALLOWED_ATTR
even though <select> and <meter> are already in ALLOWED_TAGS, so
<select size="4"> rendered at default height and <meter low/high/optimum>
lost its threshold-based gauge colouring. All four are pure
presentation/semantic attributes with no URL/script/event-handler
surface, so no security implication.
Also regenerates the pinned pre-Task-25 output fixture: its source
(html-block-test-body.html) already exercises size/low/high/optimum, so
the byte-identity test's expected output legitimately changes to include
them; verified the regenerated fixture's only diff from the prior one is
those four attributes now surviving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four issues from adversarial review of the block-scoped <style> feature:
1. (Critical) transformBlock() recursed once per @media/@supports/@container
nesting level with no cap -- ~7000 nested rules blew the call stack, and
nothing between a Custom HTML block's toHtml() and the publish pipeline
catches exceptions, so this took down the whole page's publish and
crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
(pass the body through unscoped beyond it) and wrapped scopeCss() so it
never throws on any input, matching repairOrphanNodes's existing
contract. Caught and fixed a variable-shadowing bug in my own first pass
at this: the new depth parameter was silently shadowed by a pre-existing
`let depth` used for brace-matching in the same block, which would have
defeated the cap with no type error.
2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
style-free input: it also changes how the parser preserves whitespace
after a LEADING html comment, which this repo's own fixture starts with.
Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
verbatim, run standalone against real dompurify+jsdom) that the fixture
gained bytes. Fixed by applying FORCE_BODY only when the input has a
real (non-comment) <style> tag to rescue -- confirmed empirically that
this is a true no-op for every other input. Pinned the old output as a
checked-in regression fixture and added a raw toBe() diff test.
3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
published/exported output into a fresh block nested a second wrapper
and re-prefixed every selector. Added isAlreadyScoped(), which detects
a lone root wrapper whose <style> content is already a no-op under
scopeCss for that wrapper's own class (reusing scopeCss's own
idempotency guarantee) and leaves it untouched.
4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
brute-forceable (CSS-only impact, same trust tier as other accepted
risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
<style> block when its content merely looks tag-like (e.g.
content: "<Read More>").
1155/1155 tests passing (was 1141), tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
<style> was previously in FORBID_TAGS and stripped entirely. It's now
allowed, but its CSS is rewritten by a new hand-rolled scoper
(src/utils/scope-css.ts) so a customer's rules only match inside their own
block's wrapper -- never leak out and restyle the rest of the page. The
wrapper div (class="whp-html-<hash>") is only emitted when a block actually
has surviving <style> content, so blocks that don't use it stay
byte-identical to before this change.
Key findings, both covered by tests:
- DOMPurify's body-only serialization silently drops a <style> tag that
appears before any other content in a block (the HTML5 parser implicitly
places it in <head>, which DOMPurify never looks at). Fixed with
FORCE_BODY: true.
- DOMPurify does not sanitize CSS declaration values at all (expression(),
behavior:, url() to any host all pass through verbatim) -- @import is
stripped explicitly by scopeCss() since it's the one CSS-level
exfiltration/fetch vector in scope here.
Scope identifier reuses the existing djb2 stableHash() from utils/escape.ts
(already used for this exact class of problem) over the block's own `code`
string -- deterministic, no node id, no Math.random/Date.now.
1141/1141 tests passing (was 1077), tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up on the Task 24 sanitiser widening (approved, no bypass
found). Two Important findings to close:
1. ALLOWED_URI_REGEXP's data:image/...;base64, arm sat inside the group
that appends a trailing `:` to every alternative, so it required a
second colon no real data URI has -- the clause could never match.
Confirmed dead before the fix (poster/cite/href all stripped a valid
base64 PNG data URI) and working after (all three now survive), while
javascript:/data:text/html stay blocked. Pulled the arm out into its
own top-level alternative.
2. Corrected an inaccurate comment/report claim that every allowed
attribute value goes through this regex -- `src` on
img/video/audio/source/image/track is additionally covered by
DOMPurify's own DATA_URI_TAGS allow-list, which is mimetype-blind and
bypasses the regex entirely (acceptable: none of those tags execute
src as a document; iframe is correctly excluded from that list).
Adds two regression tests: the regex fix actually working, and the
DATA_URI_TAGS bypass pinned so a future DOMPurify change surfaces as a
failing test rather than a surprise. Fixture byte figures unchanged
(15,815 -> 14,899; fixture has no data:image URIs).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A customer's broad HTML fixture showed 38% of it silently deleted by
the shipped DOMPurify config: colspan/rowspan/scope, <dl>, <sub>/<sup>,
<details>/<summary>, inline <svg>, <video>/<audio>, lang/dir/role, and
<ol start/reversed> were all stripped. The site owner's call: be
generous, this block is an explicit escape hatch, allow forms too.
Widens PURIFY_CONFIG in HtmlBlock.tsx (45->119 tags, 16->108 attrs;
form/input/button/select/textarea removed from FORBID_TAGS) while
keeping the four non-negotiables intact: no <script>, no on*, no
javascript: URLs, iframes stay sandboxed. <style> stays blocked
(separate task adds scoped support later), including inside the newly
allowed inline SVG. SVG support is an explicit tag list mirroring
DOMPurify's own SVG vocabulary rather than USE_PROFILES, which turned
out to silently discard ALLOWED_ATTR entirely and pull in unaudited
tags (dialog, template, marquee, ...) not in scope here.
Fixture survival goes from 61.6% (9,739/15,815 bytes) to 94.2%
(14,899/15,815 bytes). Adds a fixture-driven regression + security
test file (HtmlBlock.security.test.ts) plus a checked-in copy of the
reference fixture, loaded via Vite's ?raw import so tests need no new
dependencies and can't silently drift from the thing being tested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the
toolbar colour picker added in this branch was silently deleted by
DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table
tags, with tests pinning the markup path in both render and toHtml.
I3: PagesPanel's three confirmation states were not mutually exclusive;
cancelling delete revealed an unbidden reset prompt on a destructive action.
I5: orphan repair logged at console.warn, which the new console buffer
cannot see -- the reporter would never capture the most diagnostic signal
for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load
failure handling with loadState's fallback.
I7: corrects comments (and the design spec) that asserted an orphan
"renders somewhere on the canvas", which a mid-plan audit disproved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M-5: safeUrl() blocked javascript:/vbscript:/data:text/html but
allowed data:image/svg+xml, which can execute inline <script>/onload=
when loaded as a document/navigation target despite its "image" MIME
type (defense in depth -- not currently reachable to execution via
this sink, but closing it). Added `data:image/svg+xml` to the existing
DANGEROUS_SCHEME_PREFIXES check, so it's caught after the same
entity-decode/whitespace-strip/lowercase normalization used for the
other blocked schemes (obfuscated variants included). Other
data:image/* types (png/jpeg/gif/webp, ...) remain allowed unchanged.
M-6: HtmlBlock's purifyHtml() allowed <iframe src> through with no
`sandbox` attribute -- a clickjacking/phishing vector even with
DOMPurify already stripping script/on*=. Added a DOMPurify
afterSanitizeAttributes hook, scoped tightly to each purifyHtml() call
(added right before sanitize(), removed in a finally right after) so
it can't leak onto other DOMPurify uses or accumulate duplicates
across repeated calls, that force-sets a restrictive sandbox
(allow-scripts allow-same-origin allow-popups allow-forms -- no
allow-top-navigation) and referrerpolicy=no-referrer on every iframe
that survives sanitization.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each component defined a .craft.related.settings panel that was never
rendered -- the right panel renders only GuidedStyles (per-type
*StylePanel components), never .related.settings. Removed all dead
settings components across every component, their settings-only helpers
(including the dead uploadToWhp/showBrowser/handleBrowse asset-browse
blocks in Logo/Navbar/VideoBlock/FeaturesGrid, and CtasEditor in
_cta-helpers), and dropped the now-empty related keys. Render output,
.craft props/rules, and toHtml statics are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entity-escaping alone doesn't protect JS-string or raw-HTML sinks:
- HtmlBlock.toHtml exported props.code raw; now runs it through the
same purifyHtml (DOMPurify) config already used for the live editor
preview, so <script>/on*= payloads can't survive export either.
- Countdown.toHtml interpolated targetDate directly into
`new Date("${targetDate}")` inside an inline <script> -- a value
like `2026-01-01");alert(1)//` broke out of the string literal. Now
validated against a strict date/datetime shape and JSON.stringify'd
before embedding, falling back to `new Date()` for anything invalid.
- Gallery.toHtml's lightbox used
`onclick="${id}_open('${esc(img.src)}')"`, which a single quote in
img.src could break out of. Replaced with a `data-lb-src` attribute
per thumbnail and one delegated click listener on the grid
(`e.target.closest('[data-lb-src]')`) instead of a per-item inline
handler string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes XSS hole in HtmlBlock by sanitizing user/AI-supplied markup
through DOMPurify before passing to dangerouslySetInnerHTML. Adds
Vitest + jsdom for unit testing with 5 passing tests covering script
stripping, on-event handler removal, javascript: URL blocking, iframe
allowlist, and form/input stripping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>