From ca80fddec701b513e217ca5a8939b530a44622d6 Mon Sep 17 00:00:00 2001 From: Chris Zuber Date: Mon, 5 Jan 2026 16:05:22 -0800 Subject: [PATCH] Fix trusted HTML logic and update exports Corrects the logic in the trusted HTML/tagged template implementation, ensuring proper escaping and Trusted Types compliance. Updates exports to provide a unified `html` function and deprecates the previous policy creation interface. Adjusts tests to match the new API and updates patterns for improved HTML entity handling. --- CHANGELOG.md | 5 ++++ html.js | 2 +- http.config.js | 2 +- index.js | 4 +-- package-lock.json | 4 +-- package.json | 2 +- trusted-html.js | 61 +++++++++++++++++++++++--------------------- trusted-html.test.js | 20 ++++----------- 8 files changed, 49 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32ca651..8f843d5 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.3] - 2026-01-05 + +### Fixed +- Fix bad logic in the `trustedTypes` / tagged template / `trusted-html.js` implementation + ## [v1.0.2] - 2026-01-05 ### Added diff --git a/html.js b/html.js index 9068dac..6d3218a 100644 --- a/html.js +++ b/html.js @@ -1,4 +1,4 @@ -export const HTML_UNSAFE_PATTERN = /[<>"']|&(?![a-zA-Z\d]{2,5};|#\d{1,3};)/g; +export const HTML_UNSAFE_PATTERN = /[<>"']|&(?![a-zA-Z\d]{2,40};|#\d{1,6};)/g; /* eslint no-control-regex: "off" */ export const ATTR_NAME_UNSAFE_PATTERN = /[\u0000-\u001f\u007f-\u009f\s"'\\/=><&]/g; export const HTML_REPLACEMENTS = Object.freeze({ diff --git a/http.config.js b/http.config.js index c641d52..f6b25c1 100644 --- a/http.config.js +++ b/http.config.js @@ -7,7 +7,7 @@ addScriptSrc( 'https://unpkg.com/@shgysk8zer0/', ); -addTrustedTypePolicy('aegis-sanitizer#html', 'default'); +addTrustedTypePolicy('aegis-sanitizer#html', 'aegis-escape#html'); export default { routes: { diff --git a/index.js b/index.js index 0a145cd..5ef73dd 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ -export { HTML_UNSAFE_PATTERN, ATTR_NAME_UNSAFE_PATTERN, HTML_REPLACEMENTS, escapeHTML, escapeAttrName, stringifyAttr } from './html.js'; -export { createPolicy } from './trusted-html.js'; +export { HTML_UNSAFE_PATTERN, ATTR_NAME_UNSAFE_PATTERN, HTML_REPLACEMENTS, escapeHTML, escapeAttrName, stringifyAttr, html } from './html.js'; +export { html as trustedHTML } from './trusted-html.js'; export { escapeCSS } from './css.js'; diff --git a/package-lock.json b/package-lock.json index 828ed5f..88c9e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aegisjsproject/escape", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@aegisjsproject/escape", - "version": "1.0.2", + "version": "1.0.3", "funding": [ { "type": "librepay", diff --git a/package.json b/package.json index ed31340..9a59166 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aegisjsproject/escape", - "version": "1.0.2", + "version": "1.0.3", "description": "String escaping utilities for HTML and DOM attributes.", "keywords": [ "security", diff --git a/trusted-html.js b/trusted-html.js index 6aa06fd..69abf55 100644 --- a/trusted-html.js +++ b/trusted-html.js @@ -1,37 +1,40 @@ import { escapeHTML } from './html.js'; const SUPPORTS_TRUSTED_TYPES = 'trustedTypes' in globalThis; -const isTrustedHTML = SUPPORTS_TRUSTED_TYPES ? globalThis.trustedTypes.isHTML : () => false; +const POLICY_NAME = 'aegis-escape#html'; +const TRUSTED_SYMBOL = Symbol(POLICY_NAME); -function createHTML(strings, ...values) { +const isTrustedHTML = SUPPORTS_TRUSTED_TYPES + ? input => globalThis.trustedTypes.isHTML(input) + : input => typeof input === 'object' ? Object.hasOwn(input ?? {}, TRUSTED_SYMBOL) : false; + +const policy = SUPPORTS_TRUSTED_TYPES + ? globalThis.trustedTypes.createPolicy(POLICY_NAME, { createHTML: input => input }) + : Object.freeze({ + name: POLICY_NAME, + createHTML(input) { + const obj = { + toString() { + return input; + } + }; + + Object.defineProperty(obj, TRUSTED_SYMBOL, { + value: true, + enumerable: false, + writable: false, + }); + + return Object.freeze(obj); + } + }); + +export function html(strings, ...values) { if (! Array.isArray(strings) || ! Array.isArray(strings.raw)) { - return Array.isArray(strings) - ? strings.map(escapeHTML).join('') - : escapeHTML(strings); + return policy.createHTML(Array.isArray(strings) + ? strings.map(input => isTrustedHTML(input) ? input : escapeHTML(input)).join('') + : escapeHTML(strings)); } else { - return String.raw(strings, ...values.map(val => isTrustedHTML(val) ? val : escapeHTML(val))); + return policy.createHTML(String.raw(strings, ...values.map(val => isTrustedHTML(val) ? val : escapeHTML(val)))); } } - -/** - * Creates a Trusted Types policy (or a compliant fallback) for sanitizing HTML. - * The returned policy's `createHTML` method is overloaded to handle both direct string arguments - * and tagged template literals. - * - * @param {string} [name="aegis-escape#html"] - The policy name. Must match your CSP `trusted-types` directive. - * @returns {TrustedTypePolicy} A native Policy object or a frozen fallback matching the interface. - * - * @example - * const policy = createPolicy(); - * - * // 1. As a regular method: - * policy.createHTML('
Hello
'); - * - * // 2. As a tagged template: - * policy.createHTML`
${userContent}
`; - */ -export function createPolicy(name = 'aegis-escape#html') { - return SUPPORTS_TRUSTED_TYPES - ? globalThis.trustedTypes.createPolicy(name, { createHTML }) - : Object.freeze({ name, createHTML }); -} diff --git a/trusted-html.test.js b/trusted-html.test.js index b5a4e5e..1f9c11a 100644 --- a/trusted-html.test.js +++ b/trusted-html.test.js @@ -1,22 +1,18 @@ import assert from 'node:assert'; import { describe, test } from 'node:test'; -import { createPolicy } from './trusted-html.js'; - -const policy = createPolicy('default'); -const html = policy.createHTML; +import { html } from './trusted-html.js'; describe('Trusted HTML Policy (Node/Fallback Mode)', () => { - test('Tag Usage: escapes unsafe values', () => { const unsafe = ''; const result = html`
${unsafe}
`; - assert.strictEqual(result, '
<img src=x onerror=alert(1)>
'); + assert.strictEqual(result.toString(), '
<img src=x onerror=alert(1)>
'); }); test('Direct Usage: escapes input string', () => { const result = html(''); - assert.strictEqual(result, '<script>alert(1)</script>'); + assert.strictEqual(result.toString(), '<script>alert(1)</script>'); }); test('Array Usage: joins and escapes list items', () => { @@ -24,7 +20,7 @@ describe('Trusted HTML Policy (Node/Fallback Mode)', () => { const items = ['
', 'bold']; const result = html`Items: ${items}`; - assert.strictEqual(result, 'Items: <br>,<b>bold</b>'); + assert.strictEqual(result.toString(), 'Items: <br>,<b>bold</b>'); }); test('Security: enforces Double Escaping in fallback mode', () => { @@ -39,12 +35,6 @@ describe('Trusted HTML Policy (Node/Fallback Mode)', () => { 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'); + assert.strictEqual(outer.toString(), '
Safe
'); }); });