Skip to content

fix(security): scheme-check custom htmlAttributes + deny dangerous custom tags#155

Draft
DavidBabinec wants to merge 1 commit into
mainfrom
fix/htmlattributes-xss
Draft

fix(security): scheme-check custom htmlAttributes + deny dangerous custom tags#155
DavidBabinec wants to merge 1 commit into
mainfrom
fix/htmlattributes-xss

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

What & why

Custom htmlAttributes on base.link/button/image/container/text nodes were name-filtered only (event handlers, class, style, reserved data-*). href, src, srcdoc, formaction, xlink:href, … were permitted and their values never scheme-checked; the custom-tag escape hatch also accepted iframe, base, object, script, …

A low-privilege editor holding site.content.edit (including the Client role) — or a merely-imported HTML file — could store:

  • href="javascript:…" (shadowing a module's own checked href — first duplicate href wins), or
  • an <iframe srcdoc="<script>…"> (zero-click).

Because htmlAttributes is an object it bypassed escapeProps/sanitizeNodeProps, so the payload survived persistence and publish. It executed for published-page visitors and — critically — inside the admin editor canvas, which renders these trusted modules same-origin as /admin with no script-src CSP. When a higher-privileged Owner/Admin opened a page authored by a low-trust Client, the script ran in the admin origin → session/credential theft and privilege escalation.

Fix (single chokepoint + tag denylist)

All four emit/collect paths already funnel through one name gate; I extended that into a value-aware gate and applied it everywhere:

  • sanitizeRenderableHtmlAttribute(name, value) in @core/htmlAttributes — rejects non-renderable names, the raw-HTML sink srcdoc, and any value carrying a dangerous URL scheme (javascript:/vbscript:/data:) via the existing isSafeUrl. Value-level scheme checking covers every URL attribute without enumerating them; ordinary text (titles, ARIA labels, viewBox) passes unchanged.
  • Routed the shared publisher/canvas normaliser (normalizeHtmlAttributes), the <body> emit (bodyHtmlAttributes), and the HTML-import harvest (collectHtmlAttributes) through it.
  • resolveHtmlTag custom-tag escape hatch now denies dangerous elements (script, iframe, frame, frameset, object, embed, applet, base, link, meta, style) → coerced to div.

Not affected: base.video emits its own trusted <iframe> via its module htmlTag (returns 'iframe' directly), not resolveHtmlTag, so video/YouTube embeds keep working.

Impact

  • Developer: one shared, value-aware attribute gate instead of a name-only check duplicated across four sites.
  • User: closes a low-privilege → admin-canvas stored-XSS / account-takeover path. Legitimate attributes, safe URLs, and benign custom tags are unchanged.

Complementary follow-up (not in this PR): ship the admin script-src CSP that securityHeaders.ts currently defers — defense-in-depth behind this fix.

Verification

  • bun test src/__tests__/security/htmlAttributesXss.test.ts — 17 pass (srcdoc, javascript:/data:/vbscript: values, and dangerous custom tags dropped; safe attrs/URLs/tags preserved; publisher emit verified end-to-end).
  • bun test across publisher, htmlImport, base-modules, siteImport, htmlAttributes panels — 916 pass, 0 fail.
  • bunx tsc -b clean; eslint on touched files clean; no-core-barrel-deep-imports gate passes (new @core/htmlAttributes@core/html-sanitize leaf import is allowed).
  • Pre-existing unrelated failures (icon-catalog-integrity) confirmed failing on the untouched base; not part of this change.

🤖 Generated with Claude Code

…stom tags

Custom `htmlAttributes` on base.link/button/image/container/text nodes were
name-filtered only (on*/class/style/reserved). `href`, `src`, `srcdoc`,
`formaction`, `xlink:href`, … were permitted and their VALUES never
scheme-checked, and the custom-tag escape hatch accepted `iframe`, `base`,
`object`, `script`, … A low-privilege editor (`site.content.edit`, incl. the
Client role) — or a merely-imported HTML file — could store:

  - `href="javascript:…"` shadowing a module's own checked href, or
  - an `<iframe srcdoc="<script>…">`,

which executed for published-page visitors AND, critically, inside the admin
editor canvas (these trusted modules render same-origin as /admin with no
script-src CSP) when another admin opened the page → admin session/credential
theft and privilege escalation.

Fix at the single chokepoint every emit path already funnels through:

- Add `sanitizeRenderableHtmlAttribute(name, value)` in `@core/htmlAttributes`:
  rejects non-renderable names, the raw-HTML sink `srcdoc`, and any value with
  a dangerous URL scheme (javascript:/vbscript:/data:, via the existing
  `isSafeUrl`). Value-level scheme checking covers every URL attribute without
  enumerating them; ordinary text values pass unchanged.
- Route all four emit/collect paths through it: the shared publisher/canvas
  normaliser (`normalizeHtmlAttributes`), the `<body>` emit
  (`bodyHtmlAttributes`), and the HTML-import harvest (`collectHtmlAttributes`).
- Deny dangerous elements in the custom-tag escape hatch (`resolveHtmlTag`):
  script/iframe/frame/frameset/object/embed/applet/base/link/meta/style →
  coerced to `div`. `base.video` emits its own trusted <iframe> via its module
  `htmlTag`, not this resolver, so video embeds are unaffected.

Tests: new `htmlAttributesXss.test.ts` proves srcdoc/javascript:/data:/vbscript:
values and dangerous custom tags are dropped while safe attributes/URLs/tags
pass; the publisher emit is verified end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant