2026-07-12 17:27:22 -07:00
|
|
|
import { describe, test, expect } from 'vitest';
|
|
|
|
|
import { exportBodyHtml } from './html-export';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
|
|
|
|
|
* `props.animation` / `props.animationDelay` directly into
|
|
|
|
|
* `data-animation="${...}"` / `data-animation-delay="${...}"` with no
|
|
|
|
|
* escaping. Runs for EVERY node (via renderNode -> buildDataAttrs ->
|
|
|
|
|
* injectAttrs) and is reachable via AI `update_props` + deserialized saved
|
|
|
|
|
* state. A malicious value like `x"><img src=y onerror=alert(1)>` breaks
|
|
|
|
|
* out of the attribute and injects a live element.
|
|
|
|
|
*/
|
|
|
|
|
describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
|
|
|
|
|
const makeState = (props: Record<string, unknown>) =>
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
ROOT: {
|
|
|
|
|
type: { resolvedName: 'Container' },
|
|
|
|
|
isCanvas: true,
|
|
|
|
|
props: { tag: 'div', style: {}, ...props },
|
|
|
|
|
displayName: 'Container',
|
|
|
|
|
custom: {},
|
|
|
|
|
hidden: false,
|
|
|
|
|
nodes: [],
|
|
|
|
|
linkedNodes: {},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('malicious animation value is escaped -- no raw breakout, no injected <img>', () => {
|
|
|
|
|
const payload = 'x"><img src=y onerror=alert(1)>';
|
|
|
|
|
const state = makeState({ animation: payload });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
|
|
|
|
|
expect(html).not.toContain('<img src=y onerror=alert(1)>');
|
|
|
|
|
expect(html).not.toMatch(/data-animation="x">/);
|
|
|
|
|
// The attribute value must be escaped, so no raw `"` or `<` survives
|
|
|
|
|
// inside the data-animation attribute region.
|
|
|
|
|
expect(html).toContain('data-animation="x"><img src=y onerror=alert(1)>"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('malicious animationDelay value is escaped -- no raw breakout', () => {
|
|
|
|
|
const payload = 'x"><script>alert(1)</script>';
|
|
|
|
|
const state = makeState({ animation: 'fade-in', animationDelay: payload });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
|
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
|
|
|
expect(html).toContain('data-animation-delay="x"><script>alert(1)</script>"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('normal animation value still emits data-animation="fade-in" unchanged', () => {
|
|
|
|
|
const state = makeState({ animation: 'fade-in' });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
expect(html).toContain('data-animation="fade-in"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('normal animationDelay value still emits unchanged', () => {
|
|
|
|
|
const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' });
|
|
|
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
|
expect(html).toContain('data-animation-delay="0.5s"');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 17:44:59 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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>');
|
|
|
|
|
});
|
|
|
|
|
});
|