Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions html.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`<div class="${className}">${content}</div>`
*/
export function html(strings, ...values) {
return String.raw(strings, ...values.map(escapeHTML));
}

/**
* Escapes characters that are unsafe or prohibited in HTML attribute names.
Expand Down
22 changes: 21 additions & 1 deletion html.test.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -114,3 +114,23 @@ describe('Security Escaping Utils', () => {
});
});
});

describe('html tagged template', () => {
test('escapes interpolated values', () => {
const malicious = '<script>';
const output = html`<div>${malicious}</div>`;
assert.strictEqual(output, '<div>&lt;script&gt;</div>');
});

test('does not escape static template parts', () => {
const output = html`<span title="static"></span>`;
assert.strictEqual(output, '<span title="static"></span>');
});

test('handles multiple interpolations', () => {
const a = '<';
const b = '>';
const output = html`Start ${a} Middle ${b} End`;
assert.strictEqual(output, 'Start &lt; Middle &gt; End');
});
});
2 changes: 1 addition & 1 deletion http.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ addScriptSrc(
'https://unpkg.com/@shgysk8zer0/',
);

addTrustedTypePolicy('aegis-sanitizer#html');
addTrustedTypePolicy('aegis-sanitizer#html', 'default');

export default {
routes: {
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,2 +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 { escapeCSS } from './css.js';
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@aegisjsproject/escape",
"version": "1.0.1",
"version": "1.0.2",
"description": "String escaping utilities for HTML and DOM attributes.",
"keywords": [
"security",
Expand All @@ -23,6 +23,10 @@
"import": "./html.js",
"require": "./html.cjs"
},
"./trusted-html": {
"import": "./trusted-html.js",
"require": "./trusted-html.cjs"
},
"./css": {
"import": "./css.js",
"require": "./css.cjs"
Expand Down
11 changes: 11 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export default [{
plugins: [terser()],
sourcemap: true,
}],
}, {
input: 'trusted-html.js',
output: [{
file: 'trusted-html.cjs',
format: 'cjs',
}, {
file: 'trusted-html.min.js',
format: 'module',
plugins: [terser()],
sourcemap: true,
}],
}, {
input: 'css.js',
output: [{
Expand Down
37 changes: 37 additions & 0 deletions trusted-html.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { escapeHTML } from './html.js';

const SUPPORTS_TRUSTED_TYPES = 'trustedTypes' in globalThis;
const isTrustedHTML = SUPPORTS_TRUSTED_TYPES ? globalThis.trustedTypes.isHTML : () => false;

function createHTML(strings, ...values) {
if (! Array.isArray(strings) || ! Array.isArray(strings.raw)) {
return Array.isArray(strings)
? strings.map(escapeHTML).join('')
: escapeHTML(strings);
} else {
return 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('<div>Hello</div>');
*
* // 2. As a tagged template:
* policy.createHTML`<div>${userContent}</div>`;
*/
export function createPolicy(name = 'aegis-escape#html') {
return SUPPORTS_TRUSTED_TYPES
? globalThis.trustedTypes.createPolicy(name, { createHTML })
: Object.freeze({ name, createHTML });
}
50 changes: 50 additions & 0 deletions trusted-html.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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;

describe('Trusted HTML Policy (Node/Fallback Mode)', () => {

test('Tag Usage: escapes unsafe values', () => {
const unsafe = '<img src=x onerror=alert(1)>';
const result = html`<div>${unsafe}</div>`;

assert.strictEqual(result, '<div>&lt;img src=x onerror=alert(1)&gt;</div>');
});

test('Direct Usage: escapes input string', () => {
const result = html('<script>alert(1)</script>');
assert.strictEqual(result, '&lt;script&gt;alert(1)&lt;/script&gt;');
});

test('Array Usage: joins and escapes list items', () => {
// Essential to ensure arrays are not stringified with commas
const items = ['<br>', '<b>bold</b>'];
const result = html`Items: ${items}`;

assert.strictEqual(result, 'Items: &lt;br&gt;,&lt;b&gt;bold&lt;/b&gt;');
});

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`<span>Safe</span>`;
// inner is string: "<span>Safe</span>"

const outer = html`<div>${inner}</div>`;
// outer sees a string, so it escapes it: "<div>&lt;span&gt;Safe&lt;/span&gt;</div>"

assert.strictEqual(outer, '<div>&lt;span&gt;Safe&lt;/span&gt;</div>');
});

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');
});
});
Loading