fix(builder): sanitize non-string style values + allowlist element tags (XSS)
Adversarial re-review found the C1 fix incomplete plus an adjacent
same-class XSS, both reachable via the AI update_props path and
deserialized saved state:
- cssPropsToString only ran sanitizeCssValue on typeof-string values, so a
non-string style value (array/object) with a valid key skipped
sanitization entirely and was template-coerced raw into style="...",
e.g. { color: ['red', '"><img src=x onerror=alert(1)>'] }. Now every
non-number value is coerced with String() and sanitized; numbers stay
raw. sanitizeBreakoutChars also now escapes < and > (previously only ;
and ") as defense-in-depth, since values can reach it from non-string
sources.
- props.tag (Container) and props.level (Heading) were interpolated raw
into the tag position of exported HTML (`<${tag}`, `<${level}`) with no
runtime validation, letting a malicious value break out of the tag
entirely. Both are now allowlisted/clamped against their known-safe sets
(div/section/article/header/footer/main; h1-h6), falling back to
div/h2. Applied in Container's live render + toHtml, Heading's live
render + toHtml, and the typeName==='div' fallback branch in
html-export.ts's renderNode (hit for unresolved/legacy node types).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e12fb89ada
commit
7ba91d9829
8 files changed
+195
-10
No files matched your search
@@ -58,3 +58,42 @@ describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
|
||||
expect(html).toContain('data-animation-delay="0.5s"');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Adversarial re-review of C1: the `typeName === 'Container' || typeName ===
|
||||
* 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in
|
||||
* the component resolver, e.g. legacy/tampered saved state) interpolated
|
||||
* `props.tag` raw into `<${tag}` with no validation -- the same breakout
|
||||
* class as the fixed Container.toHtml bug, reachable via deserialized saved
|
||||
* state.
|
||||
*/
|
||||
describe('renderNode div-fallback allowlists props.tag', () => {
|
||||
const makeUnresolvedDivState = (tag: unknown) =>
|
||||
JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch
|
||||
isCanvas: true,
|
||||
props: { tag, style: {} },
|
||||
displayName: 'div',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
test('a malicious tag value falls back to div -- no injected <img>', () => {
|
||||
const state = makeUnresolvedDivState('div><img src=x onerror=alert(1)');
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('onerror');
|
||||
expect(html.startsWith('<div')).toBe(true);
|
||||
});
|
||||
|
||||
test('a valid tag (section) still emits <section', () => {
|
||||
const state = makeUnresolvedDivState('section');
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).toContain('<section');
|
||||
expect(html).toContain('</section>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user