591a51dcc2
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>
196 lines
6.5 KiB
TypeScript
196 lines
6.5 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import { escapeHtml, escapeAttr, safeUrl, stableHash, scopeId, sanitizeFormMethod, sanitizeInputType } from './escape';
|
|
|
|
describe('escapeHtml', () => {
|
|
test('escapes &, <, >, "', () => {
|
|
expect(escapeHtml('a & b <c> "d"')).toBe('a & b <c> "d"');
|
|
});
|
|
|
|
test('escapes ampersand first (no double-escaping)', () => {
|
|
expect(escapeHtml('&')).toBe('&amp;');
|
|
});
|
|
|
|
test('coerces non-string / null / undefined safely', () => {
|
|
expect(escapeHtml(null as any)).toBe('');
|
|
expect(escapeHtml(undefined as any)).toBe('');
|
|
expect(escapeHtml(123 as any)).toBe('123');
|
|
});
|
|
});
|
|
|
|
describe('escapeAttr', () => {
|
|
test('does everything escapeHtml does', () => {
|
|
expect(escapeAttr('a & b <c> "d"')).toBe('a & b <c> "d"');
|
|
});
|
|
|
|
test('also escapes single quote and backtick', () => {
|
|
expect(escapeAttr(`'`)).toBe(''');
|
|
expect(escapeAttr('`')).toBe('`');
|
|
});
|
|
|
|
test('combined example', () => {
|
|
expect(escapeAttr(`it's a "test" <b>\``)).toBe('it's a "test" <b>`');
|
|
});
|
|
|
|
test('coerces non-string / null / undefined safely', () => {
|
|
expect(escapeAttr(null as any)).toBe('');
|
|
expect(escapeAttr(undefined as any)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('safeUrl', () => {
|
|
test('blocks javascript: scheme', () => {
|
|
expect(safeUrl('javascript:alert(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks javascript: with leading whitespace and mixed case', () => {
|
|
expect(safeUrl(' JavaScript:alert(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks javascript: with tab/control-char obfuscation', () => {
|
|
expect(safeUrl('java\tscript:alert(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks entity-obfuscated javascript: scheme (decimal)', () => {
|
|
expect(safeUrl('javascript:alert(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks entity-obfuscated javascript: scheme (zero-padded decimal)', () => {
|
|
expect(safeUrl('java:script:alert(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks vbscript: scheme', () => {
|
|
expect(safeUrl('vbscript:msgbox(1)')).toBe('');
|
|
});
|
|
|
|
test('blocks data:text/html', () => {
|
|
expect(safeUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe('');
|
|
});
|
|
|
|
test('allows data:image/png', () => {
|
|
const s = 'data:image/png;base64,iVBORw0KGgo=';
|
|
expect(safeUrl(s)).toBe(s);
|
|
});
|
|
|
|
test.each([
|
|
'https://x.com/a?b=1&c=2',
|
|
'http://x',
|
|
'/relative/path',
|
|
'#anchor',
|
|
'mailto:a@b.com',
|
|
'tel:+15551234',
|
|
'',
|
|
])('allows %s unchanged (trimmed)', (input) => {
|
|
expect(safeUrl(input)).toBe(input.trim());
|
|
});
|
|
|
|
test('coerces non-string to empty string', () => {
|
|
expect(safeUrl(null as any)).toBe('');
|
|
expect(safeUrl(undefined as any)).toBe('');
|
|
expect(safeUrl(123 as any)).toBe('');
|
|
});
|
|
|
|
test('does not mangle query strings on allowed urls', () => {
|
|
expect(safeUrl('https://x.com/a?b=1&c=2')).toBe('https://x.com/a?b=1&c=2');
|
|
});
|
|
});
|
|
|
|
describe('stableHash', () => {
|
|
test('same input always produces the same output', () => {
|
|
expect(stableHash('hello')).toBe(stableHash('hello'));
|
|
});
|
|
|
|
test('different inputs produce different output (no collision for these cases)', () => {
|
|
expect(stableHash('hello')).not.toBe(stableHash('world'));
|
|
});
|
|
|
|
test('returns a non-empty string', () => {
|
|
expect(stableHash('')).toEqual(expect.any(String));
|
|
expect(stableHash('x').length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('scopeId', () => {
|
|
test('same node id -> identical scope id across calls (deterministic)', () => {
|
|
expect(scopeId('node-abc', 'seed', 'sb')).toBe(scopeId('node-abc', 'seed', 'sb'));
|
|
});
|
|
|
|
test('different node ids -> different, non-colliding scope ids even with the same fallback seed', () => {
|
|
const id1 = scopeId('node-a', 'same-seed', 'sb');
|
|
const id2 = scopeId('node-b', 'same-seed', 'sb');
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
|
|
test('node id is slugified (non-alphanumeric characters stripped, lowercased)', () => {
|
|
expect(scopeId('Node ID! 123', 'seed', 'sb')).toBe('sb_nodeid123');
|
|
});
|
|
|
|
test('no nodeId (legacy 2-arg call sites) falls back to a deterministic hash of the seed, not Math.random', () => {
|
|
const id1 = scopeId(undefined, 'same-seed', 'sb');
|
|
const id2 = scopeId(undefined, 'same-seed', 'sb');
|
|
expect(id1).toBe(id2);
|
|
});
|
|
|
|
test('no nodeId: different seeds fall back to different hashes', () => {
|
|
const id1 = scopeId(undefined, 'seed-one', 'sb');
|
|
const id2 = scopeId(undefined, 'seed-two', 'sb');
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
|
|
test('prefix is applied', () => {
|
|
expect(scopeId('node-abc', 'seed', 'tabs')).toMatch(/^tabs_/);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeFormMethod', () => {
|
|
test('allows GET and POST unchanged', () => {
|
|
expect(sanitizeFormMethod('GET')).toBe('GET');
|
|
expect(sanitizeFormMethod('POST')).toBe('POST');
|
|
});
|
|
|
|
test('is case-insensitive and normalizes to uppercase', () => {
|
|
expect(sanitizeFormMethod('get')).toBe('GET');
|
|
expect(sanitizeFormMethod('post')).toBe('POST');
|
|
expect(sanitizeFormMethod('PoSt')).toBe('POST');
|
|
});
|
|
|
|
test('falls back to POST for anything not in the allowlist', () => {
|
|
expect(sanitizeFormMethod('DELETE')).toBe('POST');
|
|
expect(sanitizeFormMethod('')).toBe('POST');
|
|
expect(sanitizeFormMethod(undefined as any)).toBe('POST');
|
|
});
|
|
|
|
test('neutralizes an attribute-breakout injection instead of passing it through', () => {
|
|
const malicious = 'POST"><script>alert(1)</script>';
|
|
expect(sanitizeFormMethod(malicious)).toBe('POST');
|
|
});
|
|
|
|
test('coerces non-string input safely', () => {
|
|
expect(sanitizeFormMethod(123 as any)).toBe('POST');
|
|
expect(sanitizeFormMethod(null as any)).toBe('POST');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeInputType', () => {
|
|
test('allows known-safe input types unchanged', () => {
|
|
['text', 'email', 'tel', 'number', 'password', 'url', 'search', 'date', 'checkbox', 'radio', 'hidden'].forEach((t) => {
|
|
expect(sanitizeInputType(t)).toBe(t);
|
|
});
|
|
});
|
|
|
|
test('falls back to text for anything not in the allowlist', () => {
|
|
expect(sanitizeInputType('bogus')).toBe('text');
|
|
expect(sanitizeInputType('')).toBe('text');
|
|
expect(sanitizeInputType(undefined as any)).toBe('text');
|
|
});
|
|
|
|
test('neutralizes an attribute-breakout injection instead of passing it through', () => {
|
|
const malicious = 'text"><img src=x onerror=alert(1)>';
|
|
expect(sanitizeInputType(malicious)).toBe('text');
|
|
});
|
|
|
|
test('coerces non-string input safely', () => {
|
|
expect(sanitizeInputType(123 as any)).toBe('text');
|
|
expect(sanitizeInputType(null as any)).toBe('text');
|
|
});
|
|
});
|