/** * Trusted Types helpers for CSP compatibility. * * @module bquery/security */ import { POLICY_NAME } from './constants'; import { sanitizeHtmlCore } from './sanitize-core'; import type { TrustedHTML, TrustedTypePolicy, TrustedTypesWindow } from './types'; /** Cached Trusted Types policy */ let cachedPolicy: TrustedTypePolicy | null = null; /** Whether policy initialization has been attempted (to avoid retry spam) */ let policyInitAttempted = false; /** * The one string the policy may wrap without sanitizing, armed only while * {@link trustedPreparedHtmlForSink} runs. See that function for callers. */ let passthroughHtml: string | null = null; /** * Check if Trusted Types API is available. * @returns True if Trusted Types are supported */ export const isTrustedTypesSupported = (): boolean => { return ( typeof window !== 'undefined' && typeof (window as TrustedTypesWindow).trustedTypes !== 'undefined' ); }; /** * Get or create the bQuery Trusted Types policy. * @returns The Trusted Types policy or null if unsupported */ export const getTrustedTypesPolicy = (): TrustedTypePolicy | null => { if (cachedPolicy) return cachedPolicy; if (policyInitAttempted) return null; if (typeof window === 'undefined') return null; const win = window as TrustedTypesWindow; if (!win.trustedTypes) return null; policyInitAttempted = true; try { cachedPolicy = win.trustedTypes.createPolicy(POLICY_NAME, { createHTML: (input: string) => // Markup that bQuery itself already sanitized (or that is // author-controlled by contract) is wrapped as-is; everything else // is sanitized here. The pass-through slot is module-private and // only armed for the duration of one synchronous call. passthroughHtml !== null && input === passthroughHtml ? input : sanitizeHtmlCore(input), }); return cachedPolicy; } catch (error) { // Policy may already exist or be blocked by CSP const errorMessage = error instanceof Error ? error.message : String(error); console.warn(`bQuery: Could not create Trusted Types policy "${POLICY_NAME}": ${errorMessage}`); return null; } }; /** * Create a Trusted HTML value for use with Trusted Types-enabled sites. * Falls back to regular string when Trusted Types are unavailable. * * @param html - The HTML string to wrap * @returns Trusted HTML value or sanitized string */ export const createTrustedHtml = (html: string): TrustedHTML | string => { const policy = getTrustedTypesPolicy(); if (policy) { return policy.createHTML(html); } 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. * * @example * ```ts * // Safe under an enforced `require-trusted-types-for 'script'` CSP. * element.innerHTML = trustedHtmlForSink('Hello'); * ``` */ export const trustedHtmlForSink = (rawHtml: string): string => createTrustedHtml(rawHtml) as unknown as string; /** * Returns a sink-assignable value for markup that must **not** be sanitized * again: output bQuery already sanitized with a caller-specific allow list * (component render output keeps ``, `part`, form attributes), or an * author-controlled template (`createTemplate()`), which the documented * threat model treats as trusted. The DOM sanitizer backend also uses it to * hand its input to `DOMParser.parseFromString` (itself a Trusted Types sink), * since the parsed document is inert and only reaches callers after the * allow lists have run. * * Assigning such a string straight to `innerHTML` throws under an enforced * `require-trusted-types-for 'script'` CSP, while routing it through * {@link trustedHtmlForSink} would re-sanitize it with the default allow * list and strip what the caller deliberately kept. This wraps it through * the same `bquery-sanitizer` policy — so no extra policy name has to be * allowed in the CSP — without a second sanitizer pass. Without Trusted * Types it returns the string unchanged. * * Never pass untrusted input here: this function performs no sanitization. * @internal */ export const trustedPreparedHtmlForSink = (preparedHtml: string): string => { const policy = getTrustedTypesPolicy(); if (!policy) return preparedHtml; passthroughHtml = preparedHtml; try { return policy.createHTML(preparedHtml) as unknown as string; } finally { passthroughHtml = null; } }; /** * Forget the cached policy so the next sink write re-detects Trusted Types. * Test-only: browsers never let a page remove `window.trustedTypes`. * @internal */ export const __resetTrustedTypesPolicy = (): void => { cachedPolicy = null; policyInitAttempted = false; };