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
12 changes: 4 additions & 8 deletions src/core/collection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import {
createElementFromHtml,
insertContent,
sanitizeContent,
type InsertableContent,
} from './dom';
import { trustedHtmlForSink } from '../security/trusted-types';
import { createElementFromHtml, insertContent, type InsertableContent } from './dom';
import { BQueryElement } from './element';
import { applyAll, getInnerSize, getOuterSize, isHTMLElement, toElementList } from './shared';

Expand Down Expand Up @@ -226,7 +222,7 @@ export class BQueryCollection {
if (value === undefined) {
return this.first()?.innerHTML ?? '';
}
const sanitized = sanitizeContent(value);
const sanitized = trustedHtmlForSink(value);
applyAll(this.elements, (el) => {
el.innerHTML = sanitized;
});
Expand Down Expand Up @@ -859,7 +855,7 @@ export class BQueryCollection {
private insertAll(content: InsertableContent, position: InsertPosition): void {
if (typeof content === 'string') {
// Sanitize once and reuse for all elements
const sanitized = sanitizeContent(content);
const sanitized = trustedHtmlForSink(content);
applyAll(this.elements, (el) => {
el.insertAdjacentHTML(position, sanitized);
});
Expand Down
7 changes: 4 additions & 3 deletions src/core/dom.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { sanitizeHtml } from '../security/sanitize';
import { trustedHtmlForSink } from '../security/trusted-types';
import { applyAll, toElementList } from './shared';

export type InsertableContent = string | Element | Element[];

export const sanitizeContent = (html: string): string => sanitizeHtml(html);

export const setHtml = (element: Element, html: string): void => {
element.innerHTML = sanitizeHtml(html);
element.innerHTML = trustedHtmlForSink(html);
};

export const createElementFromHtml = (html: string): Element => {
const template = document.createElement('template');
template.innerHTML = sanitizeHtml(html);
template.innerHTML = trustedHtmlForSink(html);
return template.content.firstElementChild ?? document.createElement('div');
};

Expand All @@ -21,7 +22,7 @@ export const insertContent = (
position: InsertPosition
): void => {
if (typeof content === 'string') {
target.insertAdjacentHTML(position, sanitizeHtml(content));
target.insertAdjacentHTML(position, trustedHtmlForSink(content));
return;
}

Expand Down
1 change: 1 addition & 0 deletions src/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export {
sanitizeHtml,
stripTags,
trusted,
trustedHtmlForSink,
} from './security/index';
export type { SanitizedHtml, SanitizeOptions, TrustedHtml } from './security/index';

Expand Down
7 changes: 6 additions & 1 deletion src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
export { generateNonce, hasCSPDirective } from './csp';
export { escapeHtml, sanitizeHtml as sanitize, sanitizeHtml, stripTags } from './sanitize';
export { trusted } from './trusted-html';
export { createTrustedHtml, getTrustedTypesPolicy, isTrustedTypesSupported } from './trusted-types';
export {
createTrustedHtml,
getTrustedTypesPolicy,
isTrustedTypesSupported,
trustedHtmlForSink,
} from './trusted-types';
export type { SanitizedHtml, TrustedHtml } from './trusted-html';
export type { SanitizeOptions } from './types';
8 changes: 7 additions & 1 deletion src/security/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ export type { SanitizedHtml, TrustedHtml } from './trusted-html';

/**
* Sanitize HTML string, removing dangerous elements and attributes.
* Uses Trusted Types when available for CSP compliance.
*
* Returns a branded sanitized string. The framework's own DOM-write sinks
* (`$el.html()`, `.append()`/`.before()`/`.after()`, `bq-html`, …) route
* through {@link trustedHtmlForSink}, so they produce a Trusted Types value
* under an enforced `require-trusted-types-for 'script'` CSP. If you assign
* this string to a sink yourself, wrap it with `trustedHtmlForSink` (or your
* own policy) to satisfy enforced Trusted Types.
*
* @param html - The HTML string to sanitize
* @param options - Sanitization options
Expand Down
14 changes: 14 additions & 0 deletions src/security/trusted-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,17 @@ export const createTrustedHtml = (html: string): TrustedHTML | string => {
}
return sanitizeHtmlCore(html);
};

/**
* Returns the value to assign to an HTML sink (`innerHTML` /
* `insertAdjacentHTML`). When a Trusted Types policy is active the value is a
* `TrustedHTML` object, so the write satisfies an enforced
* `require-trusted-types-for 'script'` CSP instead of throwing; otherwise it is
* the sanitized string. Sanitizes exactly once.
*
* The declared return type is `string` for ergonomic assignment to DOM sink
* setters (whose lib types expect `string`); at runtime under enforced Trusted
* Types the returned value is the `TrustedHTML` object the browser accepts.
*/
export const trustedHtmlForSink = (rawHtml: string): string =>
createTrustedHtml(rawHtml) as unknown as string;
9 changes: 7 additions & 2 deletions src/view/directives/html.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { effect } from '../../reactive/index';
import { sanitizeHtml } from '../../security/index';
import { trustedHtmlForSink } from '../../security/index';
import { evaluate } from '../evaluate';
import type { DirectiveHandler } from '../types';

/**
* Handles bq-html directive - sets innerHTML (sanitized by default).
*
* The sanitized path routes through `trustedHtmlForSink` so the write produces
* a Trusted Types value under an enforced `require-trusted-types-for 'script'`
* CSP. The opt-out (`sanitize: false`) is a deliberate raw-write escape hatch
* and is left untouched.
* @internal
*/
export const handleHtml = (sanitize: boolean): DirectiveHandler => {
return (el, expression, context, cleanups) => {
const cleanup = effect(() => {
const value = evaluate<string>(expression, context);
const html = String(value ?? '');
el.innerHTML = sanitize ? sanitizeHtml(html) : html;
el.innerHTML = sanitize ? trustedHtmlForSink(html) : html;
});
cleanups.push(cleanup);
};
Expand Down
4 changes: 2 additions & 2 deletions src/view/directives/lightweight.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { evaluate, evaluateRaw } from '../evaluate';
import { sanitizeHtml } from '../../security/index';
import { trustedHtmlForSink } from '../../security/index';
import { effect } from '../../reactive/index';
import type { DirectiveHandler } from '../types';

Expand Down Expand Up @@ -39,7 +39,7 @@ export const handleInit: DirectiveHandler = (el, expression, context) => {
export const handleHtmlSafe: DirectiveHandler = (el, expression, context, cleanups) => {
const cleanup = effect(() => {
const value = evaluate<string>(expression, context);
el.innerHTML = sanitizeHtml(String(value ?? ''));
el.innerHTML = trustedHtmlForSink(String(value ?? ''));
});
cleanups.push(cleanup);
};
Expand Down
44 changes: 44 additions & 0 deletions tests/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,50 @@ describe('security/trusted-types policy', () => {
expect(String(result)).toContain('ok');
});

it('trustedHtmlForSink sanitizes and returns a sink-assignable value (#171)', async () => {
const { trustedHtmlForSink } = await import('../src/security/trusted-types');
const result = trustedHtmlForSink('<img src=x onerror=alert(1)><b>ok</b>');
// Fallback path (no Trusted Types in happy-dom): a sanitized string that is
// safe to assign to an HTML sink.
expect(String(result)).not.toContain('onerror');
expect(String(result)).toContain('<b>ok</b>');

const host = document.createElement('div');
host.innerHTML = result;
expect(host.querySelectorAll('img[onerror]').length).toBe(0);
});

it('routes trustedHtmlForSink through the policy when Trusted Types is active (#171)', async () => {
// Fresh module instance so the cached policy state does not leak between
// tests; install a mock Trusted Types policy that brands its output.
const mod = await import(`../src/security/trusted-types?tt=${'active'}`);
const original = (window as unknown as { trustedTypes?: unknown }).trustedTypes;
const created: string[] = [];
(window as unknown as { trustedTypes: unknown }).trustedTypes = {
createPolicy: (_name: string, rules: { createHTML: (s: string) => string }) => ({
createHTML: (input: string) => {
const out = rules.createHTML(input);
created.push(out);
return { __brand: 'TrustedHTML', toString: () => out };
},
}),
};
try {
const value = mod.trustedHtmlForSink('<img src=x onerror=alert(1)>ok');
// The policy's createHTML ran (its sanitizer stripped the handler) and
// produced a branded value rather than a bare string.
expect(created.length).toBe(1);
expect(String(value)).not.toContain('onerror');
expect((value as unknown as { __brand?: string }).__brand).toBe('TrustedHTML');
} finally {
if (original === undefined) {
delete (window as unknown as { trustedTypes?: unknown }).trustedTypes;
} else {
(window as unknown as { trustedTypes: unknown }).trustedTypes = original;
}
}
});

it('logs a warning when createPolicy throws and returns null', async () => {
const { getTrustedTypesPolicy } = await import('../src/security/trusted-types');
const originalTrustedTypes = (window as unknown as { trustedTypes?: unknown }).trustedTypes;
Expand Down