From cce984508f01755d60dc92b65f076310e948cde1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 11:41:11 -0700 Subject: [PATCH] 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) --- craft/src/utils/escape.test.ts | 95 ++++++++++++++++++++++++++++++++++ craft/src/utils/escape.ts | 80 ++++++++++++++++++++++++++++ craft/src/utils/html-export.ts | 5 +- 3 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 craft/src/utils/escape.test.ts create mode 100644 craft/src/utils/escape.ts diff --git a/craft/src/utils/escape.test.ts b/craft/src/utils/escape.test.ts new file mode 100644 index 0000000..ffe47b3 --- /dev/null +++ b/craft/src/utils/escape.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect } from 'vitest'; +import { escapeHtml, escapeAttr, safeUrl } from './escape'; + +describe('escapeHtml', () => { + test('escapes &, <, >, "', () => { + expect(escapeHtml('a & b "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 "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" \``)).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'); + }); +}); diff --git a/craft/src/utils/escape.ts b/craft/src/utils/escape.ts new file mode 100644 index 0000000..6296140 --- /dev/null +++ b/craft/src/utils/escape.ts @@ -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, '"'); +} + +/** 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; +} diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index afef403..72f9934 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -1,5 +1,6 @@ import { componentResolver } from '../components/resolver'; import { cssPropsToString } from './style-helpers'; +import { escapeHtml } from './escape'; export interface ExportOptions { title?: string; @@ -198,10 +199,6 @@ ${bodyHtml}${animationScript} `; } -function escapeHtml(str: string): string { - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - /** * Export serialized Craft.js state to standalone HTML. */