feat(builder): add shared escape/safeUrl util
Adds craft/src/utils/escape.ts as the single exported escaping/URL-safety util (escapeHtml, escapeAttr, safeUrl) for later hardening tasks to consolidate the 27 divergent local copies into. html-export.ts now imports escapeHtml from it instead of keeping a private copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { escapeHtml, escapeAttr, safeUrl } 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user