Files
site-builder/craft/src/utils/escape.test.ts
T

288 lines
10 KiB
TypeScript
Raw Normal View History

import { describe, test, expect } from 'vitest';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, stableHash, scopeId, sanitizeFormMethod, sanitizeInputType } from './escape';
describe('escapeHtml', () => {
test('escapes &, <, >, "', () => {
expect(escapeHtml('a & b <c> "d"')).toBe('a &amp; b &lt;c&gt; &quot;d&quot;');
});
test('escapes ampersand first (no double-escaping)', () => {
expect(escapeHtml('&amp;')).toBe('&amp;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 &amp; b &lt;c&gt; &quot;d&quot;');
});
test('also escapes single quote and backtick', () => {
expect(escapeAttr(`'`)).toBe('&#39;');
expect(escapeAttr('`')).toBe('&#96;');
});
test('combined example', () => {
expect(escapeAttr(`it's a "test" <b>\``)).toBe('it&#39;s a &quot;test&quot; &lt;b&gt;&#96;');
});
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('&#106;avascript:alert(1)')).toBe('');
});
test('blocks entity-obfuscated javascript: scheme (zero-padded decimal)', () => {
expect(safeUrl('java&#0000058script: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('M-5: blocks data:image/svg+xml (can execute script when navigated to directly)', () => {
expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
});
test('M-5: blocks data:image/svg+xml;base64 variant', () => {
expect(safeUrl('data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9YWxlcnQoMSk+')).toBe('');
});
test('M-5: blocks obfuscated (whitespace/case) data:image/svg+xml', () => {
expect(safeUrl(' DATA:IMAGE/SVG+XML,<svg onload=alert(1)>')).toBe('');
});
test('M-5: still allows other data:image/* types unchanged', () => {
expect(safeUrl('data:image/jpeg;base64,/9j/4AAQ')).toBe('data:image/jpeg;base64,/9j/4AAQ');
expect(safeUrl('data:image/gif;base64,R0lGOD')).toBe('data:image/gif;base64,R0lGOD');
expect(safeUrl('data:image/webp;base64,UklGR')).toBe('data:image/webp;base64,UklGR');
});
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('safeImageUrl (Bug 2: image-context sink -- data:image/svg+xml is safe as an <img>/CSS url() target)', () => {
test('allows data:image/svg+xml (a raw, non-base64 SVG data URI, as Gallery/ImageBlock placeholders use)', () => {
const s = 'data:image/svg+xml,<svg/>';
expect(safeImageUrl(s)).toBe(s);
});
test('allows data:image/svg+xml;base64 variant', () => {
const s = 'data:image/svg+xml;base64,PHN2Zy8+';
expect(safeImageUrl(s)).toBe(s);
});
test('allows other data:image/* types unchanged', () => {
expect(safeImageUrl('data:image/png;base64,x')).toBe('data:image/png;base64,x');
expect(safeImageUrl('data:image/jpeg;base64,x')).toBe('data:image/jpeg;base64,x');
expect(safeImageUrl('data:image/webp;base64,x')).toBe('data:image/webp;base64,x');
expect(safeImageUrl('data:image/gif;base64,x')).toBe('data:image/gif;base64,x');
});
test('still blocks javascript: scheme', () => {
expect(safeImageUrl('javascript:alert(1)')).toBe('');
});
test('still blocks vbscript: scheme', () => {
expect(safeImageUrl('vbscript:msgbox(1)')).toBe('');
});
test('still blocks data:text/html', () => {
expect(safeImageUrl('data:text/html,x')).toBe('');
});
test('still blocks non-image data: types generally (e.g. data:application/...)', () => {
expect(safeImageUrl('data:application/javascript,alert(1)')).toBe('');
});
test('blocks obfuscated (whitespace/case) javascript: scheme, same as safeUrl', () => {
expect(safeImageUrl(' JavaScript:alert(1)')).toBe('');
expect(safeImageUrl('java\tscript:alert(1)')).toBe('');
expect(safeImageUrl('&#106;avascript:alert(1)')).toBe('');
});
test('allows ordinary http(s)/relative urls unchanged, same as safeUrl', () => {
expect(safeImageUrl('https://example.com/photo.jpg')).toBe('https://example.com/photo.jpg');
expect(safeImageUrl('/assets/photo.jpg')).toBe('/assets/photo.jpg');
expect(safeImageUrl('')).toBe('');
});
test('coerces non-string to empty string', () => {
expect(safeImageUrl(null as any)).toBe('');
expect(safeImageUrl(undefined as any)).toBe('');
});
});
describe('safeUrl still blocks data:image/svg+xml (href/iframe/navigation context unchanged by safeImageUrl addition)', () => {
test('safeUrl(data:image/svg+xml,...) is still blocked', () => {
expect(safeUrl('data:image/svg+xml,<svg onload=alert(1)>')).toBe('');
});
});
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('output is a valid CSS ident: prefix_hash', () => {
expect(scopeId('Node ID! 123', 'seed', 'sb')).toMatch(/^sb_[a-z0-9]+$/);
});
test('M-4: case-differing node ids do not collapse to the same scope (no case-fold collision)', () => {
expect(scopeId('AbC', 'seed', 'sb')).not.toBe(scopeId('abc', 'seed', 'sb'));
});
test('M-4: punctuation-differing node ids do not collapse to the same scope', () => {
expect(scopeId('a-b', 'seed', 'sb')).not.toBe(scopeId('ab', 'seed', 'sb'));
});
test('M-4: same input always yields the identical scope id', () => {
expect(scopeId('some-node-id', 'seed', 'sb')).toBe(scopeId('some-node-id', 'seed', 'sb'));
});
test('M-4: output always matches a valid CSS ident pattern', () => {
expect(scopeId('Weird!! Node--ID__123', 'seed', 'sb')).toMatch(/^[a-z]+_[a-z0-9]+$/i);
});
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');
});
});