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>
55 lines
2.7 KiB
TypeScript
55 lines
2.7 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import { FormContainer } from './FormContainer';
|
|
|
|
const toHtml = (FormContainer as any).toHtml;
|
|
|
|
describe('FormContainer.toHtml relay wiring', () => {
|
|
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
|
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
|
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
|
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
|
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
|
expect(html).toContain('name="_gotcha"');
|
|
// honeypot precedes the form's children
|
|
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
|
// marker id === action id
|
|
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
|
});
|
|
|
|
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
|
|
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
|
|
expect(html).not.toContain('WHP-FORM');
|
|
expect(html).not.toContain('_gotcha');
|
|
expect(html).toContain('action="/legacy"');
|
|
expect(html).toContain('<input name="email">');
|
|
});
|
|
|
|
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
|
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
|
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
|
expect(html1).toBe(html2);
|
|
});
|
|
|
|
test('two different node ids -> different fids', () => {
|
|
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc1');
|
|
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx' }, '<input name="email">', 'node-fc2');
|
|
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
|
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
|
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"');
|
|
});
|
|
});
|