diff --git a/CHANGELOG.md b/CHANGELOG.md index f37f425..32ca651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v1.0.2] - 2026-01-05 + +### Added +- Add `createPolicy()` to create a `TrustedTypePolicy` with a `createHTML()` that escapes untrusted input + ## [v1.0.1] - 2026-01-05 ### Added diff --git a/html.js b/html.js index c73ecc1..9068dac 100644 --- a/html.js +++ b/html.js @@ -22,6 +22,13 @@ const _str = (str, fallback = '') => (str?.toString?.() ?? fallback); export const escapeHTML = str => _str(str) .replaceAll(HTML_UNSAFE_PATTERN, char => HTML_REPLACEMENTS[char]); +/** + * Tagged template function that automatically escapes interpolated values. + * @example html`
${content}
` + */ +export function html(strings, ...values) { + return String.raw(strings, ...values.map(escapeHTML)); +} /** * Escapes characters that are unsafe or prohibited in HTML attribute names. diff --git a/html.test.js b/html.test.js index bfda068..6b95ef1 100644 --- a/html.test.js +++ b/html.test.js @@ -1,6 +1,6 @@ import assert from 'node:assert'; import { describe, test, before } from 'node:test'; -import { escapeHTML, escapeAttrName, stringifyAttr } from './html.js'; +import { escapeHTML, escapeAttrName, stringifyAttr, html } from './html.js'; // Mock DOM Attr class for Node environment class MockAttr { @@ -114,3 +114,23 @@ describe('Security Escaping Utils', () => { }); }); }); + +describe('html tagged template', () => { + test('escapes interpolated values', () => { + const malicious = ''); + assert.strictEqual(result, '<script>alert(1)</script>'); + }); + + test('Array Usage: joins and escapes list items', () => { + // Essential to ensure arrays are not stringified with commas + const items = ['
', 'bold']; + const result = html`Items: ${items}`; + + assert.strictEqual(result, 'Items: <br>,<b>bold</b>'); + }); + + test('Security: enforces Double Escaping in fallback mode', () => { + /* * CRITICAL SECURITY CHECK: + * Since we are in Node (no TrustedTypes), the output of `html` is just a string. + * If we nest this string into another template, it MUST be escaped again. + * If it wasn't, we would be vulnerable to "fake trust". + */ + const inner = html`Safe`; + // inner is string: "Safe" + + const outer = html`
${inner}
`; + // outer sees a string, so it escapes it: "
<span>Safe</span>
" + + assert.strictEqual(outer, '
<span>Safe</span>
'); + }); + + test('Interface: returns a policy-like object', () => { + // Ensures the return value matches the TrustedTypePolicy interface shape + assert.strictEqual(typeof policy.createHTML, 'function'); + assert.strictEqual(policy.name, 'default'); + }); +});