fix(builder): escape/allowlist all attribute-value sinks incl. numeric/enum props (XSS)

An adversarial pass found 5 Critical XSS sinks where props declared number/enum
in TypeScript were interpolated raw into exported HTML attribute values,
trusting the type — but nothing enforces it at runtime (AI update_props only
validates node_id; deserialized saved state is untyped JSON). Fixed all 5
(NumberCounter data-target, StarRating aria-label, FormContainer method,
ContactForm/InputField input type) plus 6 sibling sinks found by an exhaustive
audit of every attribute-value interpolation across src/components: a
JS-source injection into ContentSlider's inline setInterval script, a
prototype-pollution-adjacent allowlist gap in Section's divider-shape lookup,
TextareaField rows, Testimonials rating aria-label, HeroSimple textAlign, and
MapEmbed zoom. Adds shared sanitizeFormMethod/sanitizeInputType allowlist
helpers to utils/escape.ts alongside the existing escapeAttr/safeUrl/cssValue
primitives. Every fix is TDD'd: a malicious-value test reproduces the raw
injection against the pre-fix code, then passes after the fix.

502 tests green (npx vitest run), tsc + vite build green (npm run build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:03:44 -07:00
parent 7ba91d9829
commit 591a51dcc2
45 changed files with 1039 additions and 26 deletions
@@ -110,3 +110,19 @@ describe('ContactForm.toHtml accessibility (F2.1)', () => {
expect(ids1).toEqual(ids2);
});
});
describe('ContactForm.toHtml field type attribute sanitization', () => {
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror=');
expect(html).toContain('type="text"');
});
test('legitimate email field type still passes through unchanged', () => {
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
const { html } = toHtml({ fields }, '');
expect(html).toContain('type="email"');
});
});
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { escapeHtml, escapeAttr, slugId, cssValue } from '../../utils/escape';
import { escapeHtml, escapeAttr, slugId, cssValue, sanitizeInputType } from '../../utils/escape';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
@@ -196,7 +196,7 @@ ContactForm.craft = {
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else {
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(field.type)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
}
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).join('\n ');
@@ -0,0 +1,25 @@
import { describe, test, expect } from 'vitest';
import { FormButton } from './FormButton';
const toHtml = (FormButton as any).toHtml;
describe('FormButton.toHtml', () => {
test('normal text renders as-is', () => {
const { html } = toHtml({ text: 'Send it' }, '');
expect(html).toContain('>Send it<');
expect(html).toContain('type="submit"');
});
test('type="submit" is a hardcoded literal, not prop-driven', () => {
const { html } = toHtml({ text: 'Submit' }, '');
expect(html).toMatch(/<button type="submit"/);
});
test('text content is escaped for <, >, &, and " (consistent with escapeHtml)', () => {
const { html } = toHtml({ text: '<script>alert(1)</script> & "quoted"' }, '');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&amp;');
expect(html).toContain('&quot;quoted&quot;');
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml } from '../../utils/escape';
interface FormButtonProps {
text?: string;
@@ -74,7 +75,7 @@ FormButton.craft = {
cursor: 'pointer',
...props.style,
});
const escapedText = (props.text || 'Submit').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedText = escapeHtml(props.text || 'Submit');
return {
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
};
@@ -39,3 +39,16 @@ describe('FormContainer.toHtml relay wiring', () => {
expect(mid1).not.toBe(mid2);
});
});
describe('FormContainer.toHtml method attribute sanitization', () => {
test('malicious method value cannot break out of the attribute; falls back to POST', () => {
const { html } = toHtml({ action: '/legacy', method: 'POST"><script>alert(1)</script>' }, '');
expect(html).not.toContain('<script');
expect(html).toContain('method="POST"');
});
test('legitimate GET method still passes through unchanged (non-relay path)', () => {
const { html } = toHtml({ action: '/legacy', method: 'GET' }, '');
expect(html).toContain('method="GET"');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { relayFormWiring } from '../../utils/form-relay-wiring';
import { sanitizeFormMethod } from '../../utils/escape';
interface FormContainerProps {
action?: string;
@@ -74,7 +75,7 @@ FormContainer.craft = {
...props.style,
});
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action, nodeId);
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
const method = useRelay ? 'POST' : sanitizeFormMethod(props.method); // relay requires POST
const body = honeypot + childrenHtml; // honeypot as first child
return {
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
@@ -58,3 +58,17 @@ describe('InputField.toHtml deterministic + unique ids (thread node id, resolves
expect(id1).toBe(id2);
});
});
describe('InputField.toHtml type attribute sanitization', () => {
test('malicious type value cannot break out of the attribute; falls back to type="text"', () => {
const { html } = toHtml({ label: 'Name', name: 'name', type: 'text" autofocus onfocus="alert(1)' as any }, '');
expect(html).not.toContain('onfocus=');
expect(html).not.toContain('autofocus');
expect(html).toContain('type="text"');
});
test('legitimate number type still passes through unchanged', () => {
const { html } = toHtml({ label: 'Age', name: 'age', type: 'number' as const }, '');
expect(html).toContain('type="number"');
});
});
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
import { escapeHtml, escapeAttr, scopeId, sanitizeInputType } from '../../utils/escape';
interface InputFieldProps {
label?: string;
@@ -111,7 +111,7 @@ InputField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
<input id="${escapeAttr(fieldId)}" type="${sanitizeInputType(props.type)}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
</div>`,
};
};
@@ -0,0 +1,39 @@
import { describe, test, expect } from 'vitest';
import { SubscribeForm } from './SubscribeForm';
const toHtml = (SubscribeForm as any).toHtml;
describe('SubscribeForm.toHtml hardcoded attributes stay hardcoded (no raw prop breakout)', () => {
test('form method is always POST regardless of any injected props', () => {
const { html } = toHtml({ heading: 'Join us', method: 'GET"><script>alert(1)</script>' } as any, '');
expect(html).toContain('<form method="POST"');
expect(html).not.toContain('<script');
});
test('email input type is always "email" regardless of any injected props', () => {
const { html } = toHtml({ type: 'text"><img src=x onerror=alert(1)>' } as any, '');
expect(html).toContain('<input type="email"');
expect(html).not.toContain('<img');
expect(html).not.toContain('onerror=');
});
test('layout enum only ever feeds one of two fixed literal style strings, never raw', () => {
const { html: inlineHtml } = toHtml({ layout: 'inline' }, '');
const { html: stackedHtml } = toHtml({ layout: 'stacked' }, '');
expect(inlineHtml).toContain('flex-direction:row');
expect(stackedHtml).toContain('flex-direction:column');
});
test('malicious layout value cannot inject raw CSS/attribute breakout (falls through the isInline boolean check to the stacked literal)', () => {
const { html } = toHtml({ layout: '"><script>alert(1)</script>' as any }, '');
expect(html).not.toContain('<script');
expect(html).toContain('flex-direction:column');
});
test('normal render still produces expected structure', () => {
const { html } = toHtml({ heading: 'Subscribe', placeholder: 'you@example.com', buttonText: 'Go' }, '');
expect(html).toContain('Subscribe');
expect(html).toContain('placeholder="you@example.com"');
expect(html).toContain('>Go<');
});
});
@@ -50,3 +50,16 @@ describe('TextareaField.toHtml deterministic + unique ids (thread node id, resol
expect(id1).toBe(id2);
});
});
describe('TextareaField.toHtml rows attribute sanitization', () => {
test('malicious rows value cannot break out of the attribute; falls back to a numeric rows', () => {
const { html } = toHtml({ label: 'Message', name: 'message', rows: '4"><script>alert(1)</script>' as any }, '');
expect(html).not.toContain('<script');
expect(html).toMatch(/rows="\d+"/);
});
test('legitimate numeric rows still passes through unchanged', () => {
const { html } = toHtml({ label: 'Message', name: 'message', rows: 8 }, '');
expect(html).toContain('rows="8"');
});
});
+6 -1
View File
@@ -97,6 +97,11 @@ TextareaField.craft = {
...props.style,
});
const reqAttr = props.required ? ' required' : '';
// `rows` is declared as a TS `number` but arrives unchecked (AI update_props
// path only validates node_id; deserialized saved-state JSON is untyped at
// runtime), so a string like `4"><script>...` must be coerced to a real
// number before interpolation, not trusted as already-numeric.
const rows = Number(props.rows) || 4;
// Deterministic AND unique id: scoped on the Craft node id so the
// <label for> always matches the <textarea id> AND two TextareaField
// instances that share the same (often default) `name` -- e.g. two
@@ -113,7 +118,7 @@ TextareaField.craft = {
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${escapeAttr(String(rows))}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`,
};
};