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
2 changes: 1 addition & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,5 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npx playwright install --with-deps chromium firefox webkit
- run: npm run test:browser
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ and [Risks and Footguns](https://github.com/cure53/DOMFortify/wiki/Risks-and-Foo
Of course. [Play with DOMFortify](https://cure53.de/fortify) - throw payloads at a deliberately broken
page and watch the browser neutralize them before they reach the DOM.

There's also a [collection of standalone demos](demos/) you can read or serve locally, one per feature,
including [URL scoping with EXCLUDE / URL_CONFIG](demos/url-config-demo.html) and the
[INCLUDE allow-list](demos/include-demo.html).

## How it works

Trusted Types lets a page register one `default` policy that the browser consults for every dangerous
Expand Down Expand Up @@ -198,6 +202,12 @@ Demo: [allow one script URL](demos/allow-script-url-demo.html).
// a string is a substring match, a RegExp is test()ed, and either may be given as an array.
window.DOMFortifyConfig = { EXCLUDE: ['/admin/', /\/internal\b/] };

// INCLUDE: the allow-list complement - activate ONLY on matching URLs, inactive everywhere else.
// EXCLUDE still wins for a URL that matches both. Same matching rules as EXCLUDE. Best paired with
// page-scoped enforcement (e.g. INJECT_META): under a global enforcement header, non-included pages
// have enforcement on but no default policy, so their sinks fail closed.
window.DOMFortifyConfig = { INCLUDE: ['/admin/', '/account/'], INJECT_META: true };

// URL_CONFIG: per-URL overrides; the FIRST matching rule's own keys override the base config. Handy
// for a stricter (or looser) sanitizer config, sanitizer, or script hook on specific routes.
window.DOMFortifyConfig = {
Expand Down Expand Up @@ -254,6 +264,22 @@ const s = DOMFortify.status();
`protected` is true only when enforcement is on, DOMFortify owns the `default` policy, and the sanitizer
passed its smoke test. `reason` explains the current state in one line. Demo: [status](demos/status-demo.html).

## Browser and runtime support

DOMFortify needs native Trusted Types enforcement to do its job, and as of 2026 that is broadly
available: Trusted Types reached Baseline after Chrome and Edge (since v83, 2020), Safari (since v26,
2025), and Firefox (2026) all shipped it. On any current major browser, DOMFortify works.

| Environment | Behavior |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Current Chrome / Edge / Safari / Firefox | Full: claims the `default` policy, sanitizes HTML sinks, refuses script sinks. |
| Older browser versions without enforcement | Inert, and says so via `status()` (`ttSupported` / `enforcementActive` are `false`). It never claims protection it doesn't have. |
| Need to cover pre-enforcement versions | Pair with the [W3C Trusted Types tinyfill](https://github.com/w3c/trusted-types), so the `default` policy still runs there. The tinyfill cannot block a legacy raw-string sink without enforcement; it only guarantees the sanitize path runs for code that goes through the policy. |
| Node | Build and test only. DOMFortify is browser-only; there is no Node runtime mode. |

Each browsing context is separate: a cross-origin iframe needs its own DOMFortify. Worker contexts are
out of scope.

## What it won't do

It's a retrofit, not magic. Know the edges (the
Expand All @@ -267,8 +293,11 @@ It's a retrofit, not magic. Know the edges (the
- **Load it first.** Whoever registers the `default` policy first wins. If attacker code beats you to it,
you're worse off than before. Don't add `'allow-duplicates'`.
- **One realm at a time.** Each iframe is its own world and needs its own DOMFortify.
- **Trusted Types sinks only.** Inline handlers (`onclick=`), `style`, and `href` URLs aren't TT sinks.
Close those with a real `script-src` that drops `'unsafe-inline'`.
- **Trusted Types sinks only.** DOMFortify sanitizes the Trusted Types HTML sinks. Other sinks - `style`
and CSS injection, `javascript:` URLs, and inline handlers - sit outside that contract, and their
behavior under enforcement varies by browser. Close them definitively with a real CSP alongside the
Trusted Types one, for example `script-src 'self'; object-src 'none'; base-uri 'none'` (no
`'unsafe-inline'`).
- **One sanitizer.** A bypass in the sanitizer is a bypass in everything it guards.
- **It sanitizes a string, then the sink re-parses it.** The `default` policy returns sanitized HTML as a
string that the browser parses again in context - the serialize/re-parse step that can re-open
Expand Down
6 changes: 5 additions & 1 deletion config/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,9 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
13 changes: 13 additions & 0 deletions demos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ window.DOMFortifyConfig = {
};
```

### Scoping with INCLUDE [Link](include-demo.html)

The allow-list complement of `EXCLUDE`: activate ONLY on matching URLs and stay inactive elsewhere.
Paired with `INJECT_META` so enforcement is scoped to the same pages, this is the gradual-rollout
pattern - protect a few routes first, leave the rest untouched. Add `?admin` and reload.

```js
window.DOMFortifyConfig = {
INCLUDE: [/[?&]admin\b/], // active only here
INJECT_META: true, // and enforcement scoped to the same pages
};
```

### Meta injection (best-effort) [Link](meta-inject-demo.html)

`INJECT_META` is an opt-in attempt to add the enabling CSP `<meta>` for pages that can set neither a
Expand Down
83 changes: 83 additions & 0 deletions demos/include-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOMFortify demo: Scoping with INCLUDE</title>
<link rel="stylesheet" href="lib/demo.css" />
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<!--
No static enforcement <meta> here on purpose. INCLUDE scopes WHERE DOMFortify activates, and it
only composes safely when enforcement is scoped the same way - so we let DOMFortify turn
enforcement on itself, per page, with INJECT_META. On an out-of-scope URL it stays inactive AND
injects nothing, so that page is simply left untouched (unprotected, not broken). This is the
gradual-rollout pattern: protect /admin first, leave everything else alone.
-->
<script>
window.DOMFortifyConfig = {
INCLUDE: [/[?&]admin\b/], // activate ONLY where the URL matches
INJECT_META: true, // and let DOMFortify scope enforcement to those same pages
SANITIZER_CONFIG: { ALLOWED_TAGS: ['b', 'i', 'p', 'a', '#text'], ALLOWED_ATTR: ['href'] },
};
</script>
<script src="https://cdn.jsdelivr.net/gh/cure53/DOMFortify@main/dist/fortify.js"></script>
</head>
<body>
<main>
<nav class="crumb"><a href="README.md">DOMFortify demos</a> / Scoping with INCLUDE</nav>
<h1>Scoping with INCLUDE</h1>
<p class="lead">
<code>INCLUDE</code> is the allow-list complement of <code>EXCLUDE</code>: DOMFortify activates
<em>only</em> on matching URLs and stays inactive everywhere else. This page keys off the query
string. <code>?admin</code> is in scope, so DOMFortify claims the policy, injects the enabling
CSP, and sanitizes. The baseline URL is out of scope, so DOMFortify stands down and leaves the
page untouched.
</p>

<div class="card">
<h2>Pick a URL</h2>
<p>
<a href="?">baseline (out of scope)</a> &nbsp;|&nbsp;
<a href="?admin">?admin (in scope)</a>
</p>
<p class="muted">Current: <code id="href"></code></p>
<p class="muted">excluded = <span id="excluded"></span> &nbsp; metaInjected =
<span id="meta"></span> &nbsp; protected = <span id="protected"></span></p>
</div>

<div class="card">
<h2>Same payload on both URLs</h2>
<textarea id="dirty">&lt;p&gt;&lt;b&gt;bold&lt;/b&gt; &lt;a href="https://example.com"&gt;link&lt;/a&gt;&lt;/p&gt;&lt;img src=x onerror="alert(1)"&gt;</textarea>
<button id="run">Run the sink</button>
<div class="muted">Resulting HTML:</div>
<pre id="html"></pre>
<p id="note"></p>
</div>
</main>
<script>
window.alert = () => {}; // so an out-of-scope, unprotected page cannot actually pop a dialog
const s = (window.DOMFortify && DOMFortify.status()) || {};
document.getElementById('href').textContent = location.href;
document.getElementById('excluded').textContent = String(!!s.excluded);
document.getElementById('meta').textContent = String(!!s.metaInjected);
document.getElementById('protected').textContent = String(!!s.protected);

const out = document.createElement('div');
const run = () => {
const note = document.getElementById('note');
try {
out.innerHTML = document.getElementById('dirty').value;
document.getElementById('html').textContent = out.innerHTML;
note.innerHTML = s.protected
? '<span class="status ok">In INCLUDE scope: enforcement injected, sink sanitized.</span>'
: '<span class="status bad">Out of INCLUDE scope: DOMFortify stood down, so this page is unprotected (the raw markup went straight in). On a real site, /admin would be protected and this route left as-is.</span>';
} catch (e) {
document.getElementById('html').textContent = '';
note.innerHTML = '<span class="status bad">Sink threw: ' + e.message + '</span>';
}
};
document.getElementById('run').addEventListener('click', run);
run();
</script>
</body>
</html>
12 changes: 10 additions & 2 deletions dist/fortify.cjs.js

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

Loading