@localnerve/trusted-types-bootstrap 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +11 -0
- package/README.md +157 -0
- package/index.js +141 -0
- package/lib/escape.js +33 -0
- package/lib/sanitize.js +47 -0
- package/lib/shim.js +55 -0
- package/package.json +74 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Copyright 2026 Alex Grant, LocalNerve, LLC
|
|
2
|
+
|
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
4
|
+
|
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
6
|
+
|
|
7
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
8
|
+
|
|
9
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
10
|
+
|
|
11
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Trusted Types Bootstrap
|
|
2
|
+
|
|
3
|
+
> Page-level Trusted Types bootstrap for web apps — one import + one config call replaces the per-app inline bootstrap script
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/js/@localnerve%2Ftrusted-types-bootstrap)
|
|
6
|
+
|
|
7
|
+
Every web app that enforces `require-trusted-types-for 'script'` re-implements the same boilerplate inline bootstrap script: detect whether CSP is enforced, register the reserved `default` policy (escaping `createHTML`, same-origin-only `createScriptURL`, no `createScript`), optionally register app-named policies for static-markup passthrough, install a passthrough shim for browsers without Trusted Types, and log `securitypolicyviolation` events.
|
|
8
|
+
|
|
9
|
+
This package turns that boilerplate into a shared, parameterized export: **one import + one config call**, with the logic identical for any app — only the named policies and allowed script origins vary per project. The core (escape, same-origin guard, shim, orchestration) is tiny and dependency-free; the DOMPurify-backed sanitize tier is an opt-in sub-path so apps without rich content never pay for it.
|
|
10
|
+
|
|
11
|
+
* [Why This Exists](#why-this-exists)
|
|
12
|
+
* [Usage](#usage)
|
|
13
|
+
* [API](#api)
|
|
14
|
+
* [Sanitize Sub-Path](#sanitize-sub-path)
|
|
15
|
+
* [Relationship to Other Packages](#relationship-to-other-packages)
|
|
16
|
+
* [Development](#development)
|
|
17
|
+
|
|
18
|
+
## Why This Exists
|
|
19
|
+
|
|
20
|
+
1. **Stop re-creating the same ~90-line inline bootstrap in every web app** — drift risk across projects, duplicated review effort
|
|
21
|
+
2. **Zero cost for apps that don't need rich content** — the core escape guard + shim is a few KB and has no runtime dependencies
|
|
22
|
+
3. **Opt-in sanitize tier** — DOMPurify-backed "allow some safe HTML" support lives in a separate entry point (`/sanitize`) that only lands in bundles of apps that import it
|
|
23
|
+
4. **Compatible with computed allowlists** — named policies are declared at the call site (in your app's inline script), so [`@localnerve/trusted-types-rules`](https://github.com/localnerve/trusted-types-rules#readme) keeps detecting them via AST analysis for the CSP `trusted-types` directive — no special handling needed
|
|
24
|
+
5. **ESM-first, tree-shakeable** for bundlers (Rollup/webpack)
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
The bootstrap must run **before any other app code** — keep it as the page's first inline script so policies exist when sinks are first hit. In production builds that meta tag is present and policies register; in dev builds (no meta tag) nothing registers and plain strings pass through, exactly like a browser without Trusted Types.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
// your page's FIRST inline script
|
|
32
|
+
import { bootstrapTrustedTypes } from '@localnerve/trusted-types-bootstrap';
|
|
33
|
+
|
|
34
|
+
bootstrapTrustedTypes({
|
|
35
|
+
// 'auto' (default): enforce only when a CSP meta tag is present (prod builds).
|
|
36
|
+
// true/false to override.
|
|
37
|
+
enforce: 'auto',
|
|
38
|
+
|
|
39
|
+
// Origins allowed by the default policy's createScriptURL. Default: same-origin
|
|
40
|
+
// only. Extra entries are full origins, e.g. 'https://cdn.example.com'.
|
|
41
|
+
scriptURLOrigins: ['self'],
|
|
42
|
+
|
|
43
|
+
// App-named policies to register alongside `default`. Keys must appear in the
|
|
44
|
+
// CSP trusted-types allowlist (computed by @localnerve/trusted-types-rules).
|
|
45
|
+
policies: {
|
|
46
|
+
'jam-app-static': 'staticHtml', // pass-through createHTML: author-controlled markup only
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// optional per-policy form: custom hooks and/or a helper callback invoked
|
|
50
|
+
// after the policy is registered (e.g. to attach a convenience global)
|
|
51
|
+
// policies: {
|
|
52
|
+
// 'jam-app-static': {
|
|
53
|
+
// hooks: { createHTML: input => String(input) },
|
|
54
|
+
// helper: w => {
|
|
55
|
+
// w.trustedStaticHtml = html => w.trustedTypes.getPolicy('jam-app-static').createHTML(html);
|
|
56
|
+
// }
|
|
57
|
+
// }
|
|
58
|
+
// },
|
|
59
|
+
|
|
60
|
+
logViolations: true, // console.warn on securitypolicyviolation (dev aid)
|
|
61
|
+
shimWhenUnavailable: true // passthrough shim for browsers without TT
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The `default` policy's behavior contract:
|
|
66
|
+
|
|
67
|
+
* `createHTML` applies the industry-standard **6-char escape** (`& < > " ' \`` → `& < > " ' ``) — safe in both element-content and attribute contexts, so markup sinks render inert text instead of executing injected tags
|
|
68
|
+
* `createScriptURL` only allows origins in `scriptURLOrigins` (default same-origin), so code can never be loaded from another origin (`<script src>`, Workers, dynamic `import()`)
|
|
69
|
+
* **no** `createScript` hook — `eval()` / `new Function()` remain blocked by CSP
|
|
70
|
+
|
|
71
|
+
Custom-element / web-component policies are unaffected: components register their own named policies at their own load time (via [`@localnerve/web-component-build`](https://github.com/localnerve/web-component-build#trusted-types-helpers) helpers), exactly as they do today.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
This library exports:
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
export function bootstrapTrustedTypes (config): Boolean
|
|
79
|
+
export function defaultPolicyHooks (options): Object
|
|
80
|
+
export function setWindow (target)
|
|
81
|
+
export { escapeHtml, shimTrustedTypes }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### bootstrapTrustedTypes(config)
|
|
85
|
+
Bootstraps page-level Trusted Types. Returns `true` when policies were registered, `false` in passthrough mode (dev builds / browsers without TT). Throws if no window/document is available.
|
|
86
|
+
|
|
87
|
+
Config options:
|
|
88
|
+
|
|
89
|
+
* **enforce** {'auto'|Boolean} — default `'auto'`. `'auto'`: enforce only when a CSP meta tag (`meta[http-equiv="Content-Security-Policy"]`) is present, i.e. production builds; dev builds with no meta register nothing so plain strings pass through. `true`/`false` override detection.
|
|
90
|
+
* **scriptURLOrigins** {Array\<String\>} — origins allowed by the default policy's `createScriptURL`. Default `['self']` (same-origin only). Extra entries are full origins, e.g. `'https://cdn.example.com'`.
|
|
91
|
+
* **policies** {Object} — app-named policies to register alongside `default`. Keys must appear in the CSP `trusted-types` allowlist. Values are one of:
|
|
92
|
+
* `'staticHtml'` — pass-through preset: `createHTML` returns the input unchanged (author-controlled markup only; must never receive user-influenced data), plus a same-origin `createScriptURL`
|
|
93
|
+
* an **hooks object** — `{ createHTML, createScriptURL, createScript }` passed straight to `trustedTypes.createPolicy`
|
|
94
|
+
* `{ hooks, helper }` — hooks as above, plus an optional `helper(window)` callback invoked after registration (e.g. to attach a convenience global)
|
|
95
|
+
* **logViolations** {Boolean} — default `true`. `console.warn` on `securitypolicyviolation` events. Cheap development aid; the event never fires in a healthy prod environment.
|
|
96
|
+
* **shimWhenUnavailable** {Boolean} — default `true`. When enforcing but the browser has no Trusted Types (it ignores `require-trusted-types-for` anyway), install the passthrough shim so unconditional `trustedTypes.createPolicy(...)` calls elsewhere never throw and plain strings pass through as before.
|
|
97
|
+
|
|
98
|
+
### defaultPolicyHooks(options)
|
|
99
|
+
Returns the hook object for the reserved `default` policy, for apps that want to register policies manually (e.g. with their own timing or a custom policy name set). Accepts `{ scriptURLOrigins }` as in the config above. No `createScript` hook is included.
|
|
100
|
+
|
|
101
|
+
### escapeHtml(input)
|
|
102
|
+
Applies the industry-standard 6-character escape — `& < > " ' \`` → `& < > " ' `` — for safe interpolation into HTML markup, in element content **and** attribute values. Output is byte-identical to [`he.escape`](https://github.com/mathiasbynens/he) for all inputs where `he` does not throw; `null`/`undefined` yield `''`. Exported here so build-time or computed contexts can use the same escape the runtime `default` policy applies.
|
|
103
|
+
|
|
104
|
+
### shimTrustedTypes(window)
|
|
105
|
+
Installs the passthrough shim on a window (see [lib/shim.js](lib/shim.js)): `trustedTypes.createPolicy` returns plain-string pass-through hooks, and `document.createElement('script').src = x` never throws (the setter coerces to string). Idempotent — does nothing when `trustedTypes` already exists.
|
|
106
|
+
|
|
107
|
+
### setWindow(target)
|
|
108
|
+
Overrides the window reference used for policy registration (resolved at call time, defaulting to `globalThis`). Primarily a test hook; apps do not need it.
|
|
109
|
+
|
|
110
|
+
## Sanitize Sub-Path
|
|
111
|
+
|
|
112
|
+
The **escape tier** (default behavior) is strict: six characters escaped, nothing else rendered as markup. For rendering **untrusted rich content** (user-supplied posts, importable documents) you want the **sanitize tier**: DOMPurify strips dangerous markup while preserving an allow-list of safe HTML. It lives behind a separate entry point so only apps that import it pay the ~11 KB gzip cost:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
// Only this module imports dompurify (~11 KB gzip). Apps without rich content
|
|
116
|
+
// never pay for it.
|
|
117
|
+
import { createSanitizerPolicy } from '@localnerve/trusted-types-bootstrap/sanitize';
|
|
118
|
+
|
|
119
|
+
const policy = createSanitizerPolicy('app-rich', {
|
|
120
|
+
USE_PROFILES: { html: true }, // DOMPurify config passthrough
|
|
121
|
+
ALLOWED_TAGS: ['b', 'i', 'em', 'strong']
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// at the sink:
|
|
125
|
+
el.innerHTML = policy.createHTML(dirtyHtml);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
* **createSanitizerPolicy(name, dompurifyConfig)** — creates a named policy whose `createHTML` calls `DOMPurify.sanitize(input, { ...config, TRUSTED_TYPES_POLICY: null })`. The `TRUSTED_TYPES_POLICY: null` is forced internally (any caller-supplied value is ignored) so DOMPurify does **not** create its internal `dompurify` policy — which would break a computed CSP allowlist. Returns `null` when Trusted Types is unavailable; fall back to plain-string passthrough or use the exported `purify` instance directly.
|
|
129
|
+
* **purify** — a `DOMPurify` instance bound to the current window, for sanitizing without a policy (e.g. dev builds without enforcement).
|
|
130
|
+
|
|
131
|
+
The policy name is the first argument and must be in the CSP allowlist, detected by `@localnerve/trusted-types-rules` as usual since it is declared at this call site.
|
|
132
|
+
|
|
133
|
+
## Relationship to Other Packages
|
|
134
|
+
|
|
135
|
+
| Package | Scope | TT role |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| [`@localnerve/web-component-build`](https://github.com/localnerve/web-component-build) | web component build pipeline | **Component-level** runtime helpers (`escapeHtml`, `getTrustedPolicy`, `trustedHtml`) inlined into each component's bundle; components register their own named policy. |
|
|
138
|
+
| `@localnerve/trusted-types-bootstrap` (this) | app/page-level bootstrap | Registers the app's `default` + named policies, shim, violation logging. One call per app. |
|
|
139
|
+
| [`@localnerve/trusted-types-rules`](https://github.com/localnerve/trusted-types-rules) | build-time audit | Computes the CSP `trusted-types` allowlist from built JS (AST). Consumes output of both above; no special handling needed. |
|
|
140
|
+
|
|
141
|
+
The 6-char escape exists in two places by design: wcb's `escapeHtml` (component-level, build-time-inlined) and this package's core (app-level, runtime). They must stay byte-identical — a shared test vector (`ESCAPE_VECTORS` in [`__tests__/escape.spec.js`](__tests__/escape.spec.js), verified against `he.escape`) guards that drift.
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
npm install
|
|
147
|
+
npm test # node --test with coverage (lcov written to coverage/)
|
|
148
|
+
npm run lint # eslint
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Tests run under Node (see `.nvmrc`); the browser path is exercised with jsdom plus a fake `trustedTypes` recorder — jsdom has no native Trusted Types, and policy registration through `createPolicy` + hooks is exactly the API surface this package relies on.
|
|
152
|
+
|
|
153
|
+
## License
|
|
154
|
+
|
|
155
|
+
BSD-3-Clause — see [LICENSE.md](LICENSE.md).
|
|
156
|
+
|
|
157
|
+
Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
|
package/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-level Trusted Types bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* One import + one config call replaces the per-app inline bootstrap script:
|
|
5
|
+
* registers the reserved `default` policy (escaping createHTML, allowlisted
|
|
6
|
+
* createScriptURL, no createScript), optional app-named policies, a passthrough
|
|
7
|
+
* shim for browsers without Trusted Types, and securitypolicyviolation logging.
|
|
8
|
+
*
|
|
9
|
+
* The default config replicates jam-build's inline bootstrap exactly:
|
|
10
|
+
* - `enforce: 'auto'` registers policies only when a CSP meta tag is present
|
|
11
|
+
* (production builds); dev builds with no meta register nothing so plain
|
|
12
|
+
* strings pass through.
|
|
13
|
+
* - Named policies are declared at the call site, so
|
|
14
|
+
* @localnerve/trusted-types-rules keeps detecting them via AST analysis for
|
|
15
|
+
* the computed CSP `trusted-types` allowlist.
|
|
16
|
+
*
|
|
17
|
+
* This module must run before any other app code (e.g. as the page's first
|
|
18
|
+
* inline script) so policies exist when sinks are first hit.
|
|
19
|
+
*
|
|
20
|
+
* Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
21
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { escapeHtml } from './lib/escape.js';
|
|
25
|
+
import { shimTrustedTypes } from './lib/shim.js';
|
|
26
|
+
|
|
27
|
+
// window is resolved at call time (not import time) so bundlers and tests can
|
|
28
|
+
// control it; setWindow() overrides the global reference (used by tests).
|
|
29
|
+
let win = typeof globalThis !== 'undefined' ? globalThis : null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Override the window reference used for policy registration.
|
|
33
|
+
*
|
|
34
|
+
* @param {Window} target - The window to register policies on (or null to reset to globalThis).
|
|
35
|
+
*/
|
|
36
|
+
export function setWindow (target) {
|
|
37
|
+
win = target === undefined ? (typeof globalThis !== 'undefined' ? globalThis : null) : target;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hooks for the reserved `default` policy.
|
|
42
|
+
*
|
|
43
|
+
* - createHTML: industry-standard 6-char escape, safe in element-content and
|
|
44
|
+
* attribute contexts.
|
|
45
|
+
* - createScriptURL: only origins in scriptURLOrigins pass (default same-origin).
|
|
46
|
+
* - No createScript hook: eval() / new Function() stay blocked by CSP.
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} [options] - Options.
|
|
49
|
+
* @param {Array<String>} [options.scriptURLOrigins] - Origins allowed by createScriptURL. 'self' means same-origin (default). Extra entries are full origins, e.g. 'https://cdn.example.com'.
|
|
50
|
+
* @returns {Object} The policy hooks object.
|
|
51
|
+
*/
|
|
52
|
+
export function defaultPolicyHooks (options = {}) {
|
|
53
|
+
const origins = options.scriptURLOrigins || ['self'];
|
|
54
|
+
return {
|
|
55
|
+
createHTML: input => escapeHtml(input),
|
|
56
|
+
createScriptURL: (input, opts) => {
|
|
57
|
+
const url = new URL(String(input), opts?.baseUrl || win?.location?.href || 'http://localhost/');
|
|
58
|
+
const allowed = origins.some(o => o === 'self' ? url.origin === win.location.origin : url.origin === o);
|
|
59
|
+
if (!allowed) {
|
|
60
|
+
throw new TypeError('Trusted Types: script URL rejected by default policy: ' + url);
|
|
61
|
+
}
|
|
62
|
+
return url.href;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the hook object for a named policy from its config entry.
|
|
69
|
+
*
|
|
70
|
+
* @param {Object|String} spec - Either a hooks object ({createHTML, createScriptURL, createScript}) or 'staticHtml' for the pass-through preset (author-controlled markup only; must never receive user-influenced data).
|
|
71
|
+
* @returns {Object} The policy hooks object.
|
|
72
|
+
*/
|
|
73
|
+
function namedPolicyHooks (spec) {
|
|
74
|
+
if (typeof spec === 'string') {
|
|
75
|
+
if (spec !== 'staticHtml') {
|
|
76
|
+
throw new TypeError('trusted-types-bootstrap: unknown preset "' + spec + '"');
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
createHTML: input => String(input),
|
|
80
|
+
createScriptURL: input => new URL(String(input), win.location.href).href
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return spec;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bootstrap page-level Trusted Types.
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} [config] - Configuration.
|
|
90
|
+
* @param {'auto'|Boolean} [config.enforce='auto'] - 'auto' (default): enforce only when a CSP meta tag (meta[http-equiv="Content-Security-Policy"]) is present, i.e. production builds. true/false override detection.
|
|
91
|
+
* @param {Array<String>} [config.scriptURLOrigins] - Origins allowed by the default policy's createScriptURL. Default ['self'] (same-origin only).
|
|
92
|
+
* @param {Object} [config.policies] - App-named policies to register alongside `default`. Keys must appear in the CSP trusted-types allowlist (computed by @localnerve/trusted-types-rules). Values: 'staticHtml' preset, a hooks object, or {hooks, helper}.
|
|
93
|
+
* @param {Boolean} [config.logViolations=true] - console.warn on securitypolicyviolation events (development aid; cheap and the event never fires in healthy prod).
|
|
94
|
+
* @param {Boolean} [config.shimWhenUnavailable=true] - Install the passthrough shim when the browser has no Trusted Types, so unconditional createPolicy calls never throw.
|
|
95
|
+
*/
|
|
96
|
+
export function bootstrapTrustedTypes (config = {}) {
|
|
97
|
+
const {
|
|
98
|
+
enforce = 'auto',
|
|
99
|
+
scriptURLOrigins,
|
|
100
|
+
policies = {},
|
|
101
|
+
logViolations = true,
|
|
102
|
+
shimWhenUnavailable = true
|
|
103
|
+
} = config;
|
|
104
|
+
|
|
105
|
+
if (!win || typeof win.document === 'undefined') {
|
|
106
|
+
throw new TypeError('trusted-types-bootstrap: no window/document available');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const hasTT = 'trustedTypes' in win;
|
|
110
|
+
const cspEnforced = !!win.document.querySelector('meta[http-equiv="Content-Security-Policy"]');
|
|
111
|
+
const shouldEnforce = enforce === 'auto' ? cspEnforced : Boolean(enforce);
|
|
112
|
+
|
|
113
|
+
if (logViolations) {
|
|
114
|
+
win.addEventListener('securitypolicyviolation', e => console.warn(
|
|
115
|
+
'CSP violation:', e.violatedDirective, e.blockedURI || ''
|
|
116
|
+
));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!shouldEnforce) return false; // passthrough (dev / no CSP meta): nothing to register
|
|
120
|
+
|
|
121
|
+
if (!hasTT) {
|
|
122
|
+
// enforcing, but the browser has no Trusted Types (it ignores
|
|
123
|
+
// require-trusted-types-for anyway): shim so unconditional createPolicy
|
|
124
|
+
// calls elsewhere never throw and plain strings pass through as before.
|
|
125
|
+
if (shimWhenUnavailable) shimTrustedTypes(win);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
win.trustedTypes.createPolicy('default', defaultPolicyHooks({ scriptURLOrigins }));
|
|
130
|
+
|
|
131
|
+
for (const [name, value] of Object.entries(policies)) {
|
|
132
|
+
const hooks = typeof value === 'object' && value !== null ? value.hooks : value;
|
|
133
|
+
const helper = typeof value === 'object' && value !== null ? value.helper : null;
|
|
134
|
+
win.trustedTypes.createPolicy(name, namedPolicyHooks(hooks));
|
|
135
|
+
if (typeof helper === 'function') helper(win);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export { escapeHtml, shimTrustedTypes };
|
package/lib/escape.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Industry-standard HTML escaping - single source of truth for this package.
|
|
3
|
+
*
|
|
4
|
+
* MUST stay byte-identical to @localnerve/web-component-build's escapeHtml
|
|
5
|
+
* (the component-level, build-time-inlined copy). The shared test vector in
|
|
6
|
+
* __tests__/escape.spec.js guards against drift between the two.
|
|
7
|
+
*
|
|
8
|
+
* Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
9
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Escape a value for safe interpolation into HTML markup (text or attribute).
|
|
14
|
+
*
|
|
15
|
+
* Applies the industry-standard escape of all six characters that can break
|
|
16
|
+
* out of a text or attribute context: & < > " ' and backtick. Escaping quotes
|
|
17
|
+
* and the backtick (not just </>) is what makes the result safe to reuse
|
|
18
|
+
* inside an attribute value, not only element content. Output is byte-identical
|
|
19
|
+
* to he.escape() for all inputs where he does not throw (null/undefined).
|
|
20
|
+
*
|
|
21
|
+
* @param {Any} input - The value to escape
|
|
22
|
+
* @returns {String} The escaped string. null/undefined yield ''.
|
|
23
|
+
*/
|
|
24
|
+
export function escapeHtml (input) {
|
|
25
|
+
if (input === null || typeof input === 'undefined') return '';
|
|
26
|
+
return String(input)
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, ''')
|
|
32
|
+
.replace(/`/g, '`');
|
|
33
|
+
}
|
package/lib/sanitize.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in DOMPurify-backed sanitizer policy.
|
|
3
|
+
*
|
|
4
|
+
* Only this module imports dompurify (~11 KB gzip minified); apps that never
|
|
5
|
+
* import the `/sanitize` sub-path do not pay for it (separate entry point,
|
|
6
|
+
* tree-shakeable). Use this tier to render untrusted rich content (user posts,
|
|
7
|
+
* imported documents) - never plain escaping for rich markup.
|
|
8
|
+
*
|
|
9
|
+
* TT integration: sanitize() is called inside this policy's own createHTML with
|
|
10
|
+
* TRUSTED_TYPES_POLICY: null so DOMPurify does NOT create its internal
|
|
11
|
+
* `dompurify` policy (which would break a computed CSP allowlist).
|
|
12
|
+
*
|
|
13
|
+
* Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
14
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import DOMPurify from 'dompurify';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a named Trusted Types policy that sanitizes input with DOMPurify.
|
|
21
|
+
*
|
|
22
|
+
* The policy name must appear in the CSP `trusted-types` allowlist (detected
|
|
23
|
+
* by @localnerve/trusted-types-rules as usual - it is declared at this call site).
|
|
24
|
+
*
|
|
25
|
+
* @param {String} name - The policy name (CSP-allowlisted).
|
|
26
|
+
* @param {Object} [config] - DOMPurify configuration passthrough, e.g. {USE_PROFILES: {html: true}, ALLOWED_TAGS: ['b', 'i']}. TRUSTED_TYPES_POLICY is always forced to null; any value passed for it is ignored.
|
|
27
|
+
* @returns {TrustedTypePolicy|null} The policy when Trusted Types is available, otherwise null (callers should fall back to DOMPurify.sanitize directly, or a passthrough string).
|
|
28
|
+
*/
|
|
29
|
+
export function createSanitizerPolicy (name, config = {}) {
|
|
30
|
+
const w = typeof window !== 'undefined' ? window : null;
|
|
31
|
+
if (!w || !('trustedTypes' in w)) return null;
|
|
32
|
+
|
|
33
|
+
const dompurify = DOMPurify(w); // bind to the app's window
|
|
34
|
+
|
|
35
|
+
return w.trustedTypes.createPolicy(name, {
|
|
36
|
+
createHTML: input => dompurify.sanitize(String(input), {
|
|
37
|
+
...config,
|
|
38
|
+
TRUSTED_TYPES_POLICY: null
|
|
39
|
+
})
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* DOMPurify instance bound to the current window. Exposed so callers can also
|
|
45
|
+
* sanitize without a policy (e.g. in dev builds without enforcement).
|
|
46
|
+
*/
|
|
47
|
+
export const purify = typeof window !== 'undefined' ? DOMPurify(window) : null;
|
package/lib/shim.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passthrough shim for browsers without Trusted Types support.
|
|
3
|
+
*
|
|
4
|
+
* Browsers without Trusted Types ignore `require-trusted-types-for`, so plain
|
|
5
|
+
* strings pass through all sinks as before; the shim exists purely so code that
|
|
6
|
+
* calls `trustedTypes.createPolicy(...)` unconditionally (e.g. this package's
|
|
7
|
+
* policy registration, or web components built with wcb) never throws in those
|
|
8
|
+
* browsers.
|
|
9
|
+
*
|
|
10
|
+
* Copyright (c) 2026 Alex Grant (@localnerve), LocalNerve LLC
|
|
11
|
+
* Copyrights licensed under the BSD License. See the accompanying LICENSE file for terms.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Install a passthrough Trusted Types shim on a window.
|
|
16
|
+
*
|
|
17
|
+
* - `trustedTypes.createPolicy` returns plain-string pass-through hooks, so
|
|
18
|
+
* policy registration is a no-op with the same call signature as the real API.
|
|
19
|
+
* - `document.createElement('script').src = x` never throws: the setter coerces
|
|
20
|
+
* to a string, guarding against a sink ever receiving an unexpected type.
|
|
21
|
+
*
|
|
22
|
+
* @param {Window} w - The window object to patch.
|
|
23
|
+
*/
|
|
24
|
+
export function shimTrustedTypes (w) {
|
|
25
|
+
if ('trustedTypes' in w) return; // already available: nothing to shim
|
|
26
|
+
|
|
27
|
+
const tt = {
|
|
28
|
+
createPolicy: () => ({
|
|
29
|
+
createHTML: s => String(s),
|
|
30
|
+
createScript: s => String(s),
|
|
31
|
+
createScriptURL: s => String(s)
|
|
32
|
+
})
|
|
33
|
+
};
|
|
34
|
+
Object.defineProperty(w, 'trustedTypes', { value: tt, writable: true });
|
|
35
|
+
|
|
36
|
+
// keep <script src> assignment from throwing on a non-string in case a sink
|
|
37
|
+
// ever receives an unexpected type; everything else passes through untouched
|
|
38
|
+
const origCreateElement = w.document.createElement.bind(w.document);
|
|
39
|
+
w.document.createElement = (tagName, ...rest) => {
|
|
40
|
+
const el = origCreateElement(tagName, ...rest);
|
|
41
|
+
if (/^script$/i.test(tagName)) {
|
|
42
|
+
Object.defineProperty(el, 'src', {
|
|
43
|
+
set (v) {
|
|
44
|
+
try {
|
|
45
|
+
el.setAttribute('src', String(v));
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
},
|
|
48
|
+
get () {
|
|
49
|
+
return el.getAttribute('src');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return el;
|
|
54
|
+
};
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@localnerve/trusted-types-bootstrap",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Page-level Trusted Types bootstrap for web apps: one import + config call replaces the per-app inline bootstrap script. Optional DOMPurify-backed sanitizer policy sub-path.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./sanitize": "./lib/sanitize.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"lint": "eslint .",
|
|
13
|
+
"pretest": "node -e \"const fs=require('fs');fs.rmSync('./coverage',{recursive:true,force:true});fs.mkdirSync('./coverage',{recursive:true})\"",
|
|
14
|
+
"test": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info --test-coverage-exclude=\"__tests__/**\" ./__tests__/*.spec.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"index.js",
|
|
18
|
+
"lib"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/localnerve/trusted-types-bootstrap.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"trusted types",
|
|
26
|
+
"csp",
|
|
27
|
+
"content security policy",
|
|
28
|
+
"xss",
|
|
29
|
+
"sanitize",
|
|
30
|
+
"web security",
|
|
31
|
+
"bootstrap"
|
|
32
|
+
],
|
|
33
|
+
"author": {
|
|
34
|
+
"name": "Alex Grant",
|
|
35
|
+
"email": "alex@localnerve.com",
|
|
36
|
+
"url": "https://www.localnerve.com"
|
|
37
|
+
},
|
|
38
|
+
"maintainers": [
|
|
39
|
+
{
|
|
40
|
+
"name": "Alex Grant",
|
|
41
|
+
"email": "alex@localnerve.com",
|
|
42
|
+
"url": "https://www.localnerve.com"
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"license": "BSD-3-Clause",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/localnerve/trusted-types-bootstrap/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/localnerve/trusted-types-bootstrap#readme",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"dompurify": "^3.4.14"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@eslint/js": "^10.0.1",
|
|
55
|
+
"eslint": "^10.9.1",
|
|
56
|
+
"eslint-plugin-n": "^18.3.0",
|
|
57
|
+
"globals": "^17.12.0",
|
|
58
|
+
"he": "^1.2.0",
|
|
59
|
+
"jsdom": "^30.0.1"
|
|
60
|
+
},
|
|
61
|
+
"funding": [
|
|
62
|
+
{
|
|
63
|
+
"type": "github",
|
|
64
|
+
"url": "https://github.com/sponsors/localnerve"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"type": "paypal",
|
|
68
|
+
"url": "https://www.paypal.com/ncp/payment/DHCB5GUYMGX5U"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=22"
|
|
73
|
+
}
|
|
74
|
+
}
|