Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Shared HTML-escaping and URL-safety utilities.
|
||||||
|
*
|
||||||
|
* `escapeHtml` / `escapeAttr` are the single source of truth for escaping
|
||||||
|
* user-supplied strings before they are interpolated into exported/rendered
|
||||||
|
* HTML. `safeUrl` blocks dangerous URL schemes (javascript:, vbscript:,
|
||||||
|
* data:text/html) including whitespace/case/entity-obfuscated variants.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Escapes &, <, >, " for safe use in text content and double-quoted attributes. */
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
if (typeof s !== 'string') {
|
||||||
|
if (s === null || s === undefined) return '';
|
||||||
|
s = String(s);
|
||||||
|
}
|
||||||
|
// Ampersand MUST be escaped first, otherwise the entities produced by the
|
||||||
|
// other replacements would themselves be re-escaped (double-escaping).
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes everything escapeHtml does, plus ' and ` for use in any attribute context. */
|
||||||
|
export function escapeAttr(s: string): string {
|
||||||
|
if (typeof s !== 'string') {
|
||||||
|
if (s === null || s === undefined) return '';
|
||||||
|
s = String(s);
|
||||||
|
}
|
||||||
|
return escapeHtml(s)
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/`/g, '`');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dangerous scheme prefixes, checked against a normalized copy with all
|
||||||
|
// colons removed (see safeUrl) so that colon-splicing obfuscation like
|
||||||
|
// "java:script:alert(1)" can't slip past a literal "javascript:" check.
|
||||||
|
const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html'];
|
||||||
|
|
||||||
|
/** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */
|
||||||
|
function decodeNumericEntities(s: string): string {
|
||||||
|
return s.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||||
|
.replace(/�*([0-9]+);?/g, (_m, dec) => String.fromCharCode(parseInt(dec, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the trimmed input if it is a safe URL, otherwise returns ''.
|
||||||
|
* Blocks javascript:, vbscript:, and data:text/html schemes, including
|
||||||
|
* whitespace / control-char / case / HTML-entity obfuscated variants.
|
||||||
|
* Allowed URLs are returned UNCHANGED (trimmed only) -- query strings etc.
|
||||||
|
* are never mangled.
|
||||||
|
*/
|
||||||
|
export function safeUrl(s: string): string {
|
||||||
|
if (typeof s !== 'string') return '';
|
||||||
|
|
||||||
|
const trimmed = s.trim();
|
||||||
|
if (trimmed === '') return '';
|
||||||
|
|
||||||
|
// Build a normalized copy for scheme detection only: decode numeric HTML
|
||||||
|
// entities (catches e.g. j -> 'j'), strip whitespace/control chars,
|
||||||
|
// and lowercase.
|
||||||
|
const normalized = decodeNumericEntities(trimmed)
|
||||||
|
.replace(/[\x00-\x20]+/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Strip any colons before matching the scheme prefix. This defeats
|
||||||
|
// obfuscation that splices extra colons into the scheme name itself
|
||||||
|
// (e.g. decoding : mid-word produces "java:script:alert(1)", which
|
||||||
|
// would otherwise dodge a literal "^javascript:" check) while still
|
||||||
|
// reliably catching the real "javascript:"/"vbscript:"/"data:text/html"
|
||||||
|
// prefixes once their own colon is removed.
|
||||||
|
const collapsed = normalized.replace(/:/g, '');
|
||||||
|
|
||||||
|
if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { componentResolver } from '../components/resolver';
|
import { componentResolver } from '../components/resolver';
|
||||||
import { cssPropsToString } from './style-helpers';
|
import { cssPropsToString } from './style-helpers';
|
||||||
|
import { escapeHtml } from './escape';
|
||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -198,10 +199,6 @@ ${bodyHtml}${animationScript}
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export serialized Craft.js state to standalone HTML.
|
* Export serialized Craft.js state to standalone HTML.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user