Security
bQuery sanitizes DOM writes by default and supports Trusted Types. Use the security module directly when you need explicit control over sanitization and CSP helpers.
As of 1.3.0, the security module is internally modularized (sanitize core, Trusted Types, CSP helpers, and constants). Import from @bquery/bquery/security for the stable public API surface.
import {
createTrustedHtml,
escapeHtml,
generateNonce,
getTrustedTypesPolicy,
hasCSPDirective,
isTrustedTypesSupported,
sanitize,
sanitizeHtml,
stripTags,
trusted,
trustedHtmlForSink,
} from '@bquery/bquery/security';Sanitization
sanitizeHtml() / sanitize()
Sanitizes an HTML string by removing dangerous elements, attributes, and protocols. Returns a branded SanitizedHtml type to indicate the string has been processed.
sanitize and sanitizeHtml are aliases — they refer to the same function.
function sanitizeHtml(html: string, options?: SanitizeOptions): SanitizedHtml;| Parameter | Type | Description |
|---|---|---|
html | string | The HTML string to sanitize |
options | SanitizeOptions | Optional configuration (see below) |
SanitizeOptions
interface SanitizeOptions {
/** Additional tags to allow beyond the default safe set. */
allowTags?: string[];
/** Additional attributes to allow beyond the default safe set. */
allowAttributes?: string[];
/** Whether to allow `data-*` attributes. Default: `true` */
allowDataAttributes?: boolean;
/** Strip all tags and return plain text. Default: `false` */
stripAllTags?: boolean;
}SanitizedHtml
type SanitizedHtml = string & { readonly [sanitizedHtmlBrand]: true };A branded string type that indicates the value was produced by sanitizeHtml().
Examples
Basic sanitization:
const safe = sanitizeHtml('<div onclick="alert(1)">Hello</div>');
// Result: '<div>Hello</div>'Script removal:
const safe = sanitizeHtml('<p>Hi</p><script>alert("xss")</script>');
// Result: '<p>Hi</p>'Allow custom elements:
const safe = sanitizeHtml('<x-icon data-name="ok"></x-icon>', {
allowTags: ['x-icon'],
allowAttributes: ['data-name'],
});
// Result: '<x-icon data-name="ok"></x-icon>'Strip all tags:
const text = sanitizeHtml('<p>Hello <strong>world</strong></p>', {
stripAllTags: true,
});
// Result: 'Hello world'What Gets Removed
Dangerous Elements
The following elements are removed by default. Tags listed in DANGEROUS_TAGS are blocked even if you try to allowlist them. slot is not in that hard block list: it is disallowed by default, but can be explicitly allowlisted for component/shadow-DOM use cases.
| Category | Elements |
|---|---|
| Scripts & styles | script, style, noscript |
| Frames | iframe, frame, frameset |
| Embedded objects | object, embed, applet |
| Parsing vectors | svg, math, foreignobject |
| Template slots | template (slot is disallowed by default, but can be allowlisted) |
| Document metadata | base, meta, link |
Dangerous Attributes
| Pattern | Examples |
|---|---|
| Event handlers | onclick, onload, onerror, etc. |
| Form actions | formaction |
| XML namespaces | xlink:*, xmlns:* |
Dangerous URL Protocols
URLs in href, src, action, and srcset are validated. These protocols are blocked:
javascript:data:vbscript:file:
DOM Clobbering Protection
Reserved IDs and names are stripped to prevent DOM clobbering attacks:
// The id attribute is stripped because 'cookie' is reserved
sanitizeHtml('<form id="cookie">...</form>');Reserved names include: document, window, location, navigator, cookie, domain, referrer, body, head, forms, images, links, scripts, children, parentNode, firstChild, lastChild, innerHTML, outerHTML, textContent.
Srcset Validation
Each URL in srcset attributes is validated individually. If any entry contains an unsafe URL, the entire srcset attribute is removed:
sanitizeHtml('<img srcset="safe.jpg 1x, javascript:alert(1) 2x">');
// Result: <img> (entire srcset removed)Form Action Validation
The action attribute on <form> elements is validated as a URL attribute:
sanitizeHtml('<form action="javascript:alert(1)">...</form>');
// Result: <form>...</form> (action removed)Unicode Bypass Protection
Zero-width Unicode characters are stripped from URLs to prevent bypass attacks:
sanitizeHtml('<a href="java\u200Bscript:alert(1)">click</a>');
// Result: <a>click</a> (href removed)Automatic Link Security
bQuery automatically adds rel="noopener noreferrer" to links that:
- Have
target="_blank"(open in new window/tab) - Point to external domains (different origin)
// target="_blank" gets security attributes
sanitizeHtml('<a href="/page" target="_blank">Link</a>');
// Result: <a href="/page" target="_blank" rel="noopener noreferrer">Link</a>
// External links get security attributes automatically
sanitizeHtml('<a href="https://external.com">Link</a>');
// Result: <a href="https://external.com" rel="noopener noreferrer">Link</a>
// Existing rel values are preserved
sanitizeHtml('<a href="https://external.com" rel="author">Link</a>');
// Result: <a href="https://external.com" rel="author noopener noreferrer">Link</a>
// Internal links without target="_blank" are unchanged
sanitizeHtml('<a href="/internal">Link</a>');
// Result: <a href="/internal">Link</a>Runtime backends
sanitizeHtml() and stripTags() work on any runtime, with or without a DOM. Two backends implement the same policy, and the right one is picked automatically:
| Backend | Used when | How it parses |
|---|---|---|
'dom' | document and DOMParser both exist | DOMParser into an inert document |
'string' | Anywhere else — Bun, Node, Deno, workers | A DOM-free scanner |
Before this existed, calling sanitizeHtml() on a server threw ReferenceError: document is not defined — sanitizing user HTML server-side meant installing linkedom or happy-dom and wiring globals.
configureSanitizer()
Pin the backend when you do not want it inferred.
function configureSanitizer(options: { backend?: 'auto' | 'dom' | 'string' }): void;
function getSanitizerConfig(): { backend: 'auto' | 'dom' | 'string' };import { configureSanitizer } from '@bquery/bquery/security';
// Identical output on the server and in the browser — useful when sanitized
// HTML is rendered on the server and hydrated on the client.
configureSanitizer({ backend: 'string' });
// Force the DOM backend after installing a DOM implementation's globals.
configureSanitizer({ backend: 'dom' });'dom' throws where no DOM exists; that is the point of asking for it explicitly. 'auto' is the default and never throws.
Differences between the backends
Both enforce exactly the same policy — the allow lists, URL checks, DOM-clobbering protection, rel="noopener noreferrer" handling and the mutation-XSS guard all live in one shared module, so they cannot drift.
What they can differ on is tree construction, because the string backend is a scanner rather than a full HTML5 parser. HTML's parsing algorithm inserts and closes elements that were never written, and a scanner does not:
sanitizeHtml('<div><p>a<div>b</p></div>');
// dom: '<div><p>a</p><div>b</div></div>' `<div>` implies `</p>`
// string: '<div><p>a<div>b</div></p></div>' nesting as written
sanitizeHtml('<table><tr><td>x</td></tr></table>');
// dom: '<table><tbody><tr><td>x</td></tr></tbody></table>' implied <tbody>
// string: '<table><tr><td>x</td></tr></table>'
stripTags('<head><title>t</title></head><body>b</body>');
// dom: 'b' DOMParser sorts <title> into the head, which is discarded
// string: 'tb' a token scanner has no notion of head and bodyBoth outputs are safe in every case — the policy is what decides that, and the policy is shared. What differs is structure, so the difference matters when server and client output must match byte for byte, not when deciding whether something is inert.
The same applies, more visibly, to malformed input, where the string backend is the more conservative of the two: it escapes what it cannot interpret instead of guessing, and drops anything it cannot serialize safely rather than emitting markup whose meaning depends on how forgiving the reader's parser is.
Policy behaviour does not differ, including where that is not obvious: a disallowed element takes its subtree with it under both backends (<unknown><b>hi</b></unknown> yields nothing, not <b>hi</b>), and text extraction drops the content of <script> and <style> under both (stripTags('<script>alert(1)</script>') is '', not 'alert(1)').
sanitizeHtml() reduces a whole document to its body content under both, so <head> never reaches the output. stripTags() is where the third example above comes from and does not share that guarantee: the DOM backend discards the head because DOMParser built one, while the string backend has no head to discard and returns its text along with the body's.
Character references are the one other place they can differ. The DOM backend decodes them with the browser's parser; the string backend — and the DOM backend's shortcut for input with no tags, which never reaches DOMParser — decode with the same rules (case-sensitive names, ; required except for the legacy names, the attribute rule that keeps ?a=1©=2 intact) but a smaller table: every HTML 4.01 name, every name whose value is ASCII, and every legacy name. A name outside it, such as the HTML5-only ☆, stays literal:
sanitizeHtml('café — :'); // 'café — :' under both
sanitizeHtml('<b>☆</b>');
// dom: '<b>☆</b>'
// string: '<b>&star;</b>' shown as the reference, never mis-decodedThe full HTML5 table would add about 7 kB gzipped to every module that sanitizes, including core.
If byte-identical output across environments matters to you, pin backend: 'string' everywhere.
HTML Escaping
escapeHtml()
Converts text into safe HTML entities. Use this when you need to display user text as content without interpreting it as markup.
function escapeHtml(text: string): string;escapeHtml('<script>alert(1)</script>');
// '<script>alert(1)</script>'
escapeHtml('"Hello" & \'World\'');
// '"Hello" & 'World''
escapeHtml('Normal text');
// 'Normal text'Tag Stripping
stripTags()
Removes all HTML tags and returns plain text content.
function stripTags(html: string): string;stripTags('<p>Hello <strong>World</strong></p>');
// 'Hello World'
stripTags('<div><ul><li>Item 1</li><li>Item 2</li></ul></div>');
// 'Item 1Item 2'Trusted Fragment Composition
trusted()
Marks a sanitized HTML string for verbatim splicing into safeHtml templates. The fragment is inserted as-is without being escaped again.
function trusted(html: SanitizedHtml): TrustedHtml;TrustedHtml
type TrustedHtml = {
readonly [trustedHtmlBrand]: true;
toString(): string;
};This is especially useful for component templates and Storybook stories where some fragments are framework-authored and already sanitized, while user data should still be escaped.
import { safeHtml } from '@bquery/bquery/component';
import { sanitizeHtml, trusted } from '@bquery/bquery/security';
const badge = trusted(sanitizeHtml('<span class="icon">♥</span>'));
const markup = safeHtml`<button>${badge}<span>Save</span></button>`;
// The badge HTML is inserted verbatim, while other interpolations are escapedWithout trusted():
// This would DOUBLE-escape the badge HTML
const markup = safeHtml`<button>${'<span class="icon">♥</span>'}<span>Save</span></button>`;
// Result: <span class="icon">♥</span><span>Save</span>Trusted Types & CSP
These helpers integrate with the browser's Trusted Types API for sites that enforce Trusted Types via Content Security Policy.
isTrustedTypesSupported()
Checks if the Trusted Types API is available in the current environment.
function isTrustedTypesSupported(): boolean;if (isTrustedTypesSupported()) {
console.log('Trusted Types are available');
}getTrustedTypesPolicy()
Gets or creates the bQuery Trusted Types policy (named 'bquery-sanitizer').
function getTrustedTypesPolicy(): TrustedTypePolicy | null;const policy = getTrustedTypesPolicy();
if (policy) {
const html = policy.createHTML('<div>Safe</div>');
element.innerHTML = html as unknown as string;
}createTrustedHtml()
Creates a Trusted HTML value that passes Trusted Types enforcement. Falls back to sanitized string output when Trusted Types are not available.
function createTrustedHtml(html: string): TrustedHTML | string;const trusted = createTrustedHtml('<div>Safe content</div>');
element.innerHTML = trusted.toString();trustedHtmlForSink()
Returns the value to assign to an HTML sink (innerHTML / insertAdjacentHTML). When a Trusted Types policy is active the return value is a TrustedHTML object — so the write satisfies an enforced require-trusted-types-for 'script' CSP instead of throwing — and the sanitized string otherwise. The input is sanitized exactly once.
The framework's own DOM-write sinks ($el.html(), .append() / .before() / .after(), the default-sanitized bq-html, and bq-html-safe) route through this helper. Call it yourself when you assign sanitized HTML to a sink under an enforced Trusted Types policy.
function trustedHtmlForSink(rawHtml: string): string;// Safe under an enforced `require-trusted-types-for 'script'` CSP.
element.innerHTML = trustedHtmlForSink('<div>Safe content</div>');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 value is the TrustedHTML object the browser accepts.
generateNonce()
Generates a cryptographically random nonce for inline scripts and styles (CSP compliance).
function generateNonce(length?: number): string;| Parameter | Type | Default | Description |
|---|---|---|---|
length | number | 16 | Nonce length in bytes (max: 1024) |
Throws: RangeError if length is less than 1, non-integer, or exceeds 1024. Throws: Error if crypto.getRandomValues or btoa are unavailable.
const nonce = generateNonce();
// URL-safe Base64 (base64url) string without padding, e.g., 'dGhpcy1pc19hLXRlc3Q'
const longNonce = generateNonce(32);hasCSPDirective()
Checks if a specific CSP directive appears to be enforced. Useful for feature detection.
function hasCSPDirective(directive: string): boolean;if (hasCSPDirective('require-trusted-types-for')) {
const trusted = createTrustedHtml('<strong>Safe</strong>');
element.innerHTML = trusted.toString();
} else {
element.innerHTML = sanitizeHtml('<strong>Safe</strong>');
}Type Definitions
TrustedTypePolicy
interface TrustedTypePolicy {
createHTML: (input: string) => TrustedHTML;
}TrustedHTML
interface TrustedHTML {
toString(): string;
}Full Example
import {
sanitizeHtml,
escapeHtml,
stripTags,
trusted,
createTrustedHtml,
generateNonce,
hasCSPDirective,
isTrustedTypesSupported,
} from '@bquery/bquery/security';
import { safeHtml } from '@bquery/bquery/component';
// 1. Sanitize user input
const userHtml = '<div onclick="steal()">Hello <b>World</b></div>';
const safe = sanitizeHtml(userHtml);
// '<div>Hello <b>World</b></div>'
// 2. Escape text for display
const userText = '<script>alert("xss")</script>';
const escaped = escapeHtml(userText);
// '<script>alert("xss")</script>'
// 3. Strip to plain text
const plain = stripTags('<p>Hello <em>World</em></p>');
// 'Hello World'
// 4. Compose trusted fragments
const badge = trusted(sanitizeHtml('<span class="badge">New</span>'));
const template = safeHtml`<button>${badge} Save</button>`;
// 5. CSP and Trusted Types integration
if (isTrustedTypesSupported()) {
const trustedHtml = createTrustedHtml('<div>Content</div>');
document.getElementById('content')!.innerHTML = trustedHtml.toString();
}
// 6. Generate nonce for inline scripts
const nonce = generateNonce();
const script = document.createElement('script');
script.nonce = nonce;
script.textContent = 'console.log("safe inline script")';
document.head.appendChild(script);Notes
sanitizeHtml()parses withDOMParserunder the'dom'backend. Under'auto'— the default — the DOM-free string backend is selected instead when nodocumentexists, so on Bun, Node and Deno noDOMParseris involved. See Runtime backends.- bQuery's standard core HTML-writing methods sanitize untrusted content by default with
sanitizeHtml(). Explicit escape hatches such ashtmlUnsafe()and other raw DOM writes bypass sanitization, so use them only with content you already trust. - The sanitizer handles nested and recursive attack vectors.
trusted()should only be used with values you have already sanitized — never with raw user input.- The default allowed tag set includes a broad set of safe HTML elements.
generateNonce()requires a secure context (crypto.getRandomValues).
Pitfalls and gotchas
- Never write
innerHTML = untrustedInput— usesanitizeHtml()orbq-html-safefirst. trusted()is an escape hatch for already-sanitized content; never feed it raw user input.- Trusted Types policies must be created early in app startup; later creation may fail under strict CSP.
- Custom sanitizer config (allowed tags / attrs) replaces the default — spread
defaultSanitizerOptionsto extend rather than replace. - View and concurrency intentionally use
new Function(...)— deployments under strict CSP must allow'unsafe-eval'or avoid those modules.
Performance notes
- The sanitizer is a linear scanner; pre-sanitize templates once at build time when possible.
- Cache nonces per request, not per element.
Testing this module
- Round-trip sanitizer tests with known XSS vectors live in
tests/security.test.ts— use them as a template.
Related modules
- View —
bq-html-safedefers to this sanitizer. - Component — sanitize untrusted props before interpolation.
- SSR — server-rendered HTML benefits from the same default sanitization.
Version history
- 1.18.0 — the DOM backend of
sanitizeHtml()drops HTML comments, and every child that is not text from elements a browser parses as text (textarea,title,xmp, …), as the string backend does. - 1.17.1 — the DOM sanitizer hands its input to
DOMParserthrough thebquery-sanitizerTrusted Types policy, sosanitizeHtml()works under an enforcedrequire-trusted-types-for 'script'CSP, provided the page's CSP allows thebquery-sanitizerpolicy name (atrusted-typesdirective that omits it makes policy creation fail, and the raw string then throws). - 1.17.0 —
sanitizeHtml()andstripTags()work without a DOM (Node, Bun, Deno); character references decode as in a browser;stripAllTags: trueoutput is escaped. - Sanitizer, Trusted Types helpers, and CSP nonce utilities have shipped since
1.0.0and remain the default for HTML-writing APIs.