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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,19 @@ 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.** 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'`).
- **Trusted Types sinks only.** DOMFortify covers exactly the Trusted Types sinks, and inside that
contract it is thorough. Every HTML sink (`innerHTML`, `outerHTML`, `insertAdjacentHTML`,
`document.write`, `Range.createContextualFragment`, `iframe.srcdoc`) is sanitized, so inline event
handlers and `javascript:` URLs that arrive as markup are stripped; every string-to-code sink
(`eval`, `Function`, string `setTimeout`/`setInterval`, `script.text`, `script.src`, Worker URLs) is
refused; and `setAttribute('onclick', ...)` is refused too, since the browser treats event-handler
content attributes as a TrustedScript sink. What sits outside the contract is only the residue no
Trusted Types policy can see: assigning a function to a handler property (`el.onclick = fn`, which
already presupposes script execution and is not reachable by markup injection), assigning a
`javascript:` URL straight to a property (`a.href = '...'`), and `style`/CSS injection. Close those
definitively with a real CSP alongside the Trusted Types one, for example
`script-src 'self'; object-src 'none'; base-uri 'none'` (no `'unsafe-inline'`). This boundary is
pinned by the `sink-boundary` e2e matrix.
- **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
83 changes: 83 additions & 0 deletions test/e2e/sink-boundary.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Sink-boundary coverage matrix.
*
* Proves, vector by vector, exactly where DOMFortify's protection begins and ends, so a future change
* cannot silently reopen a sink. Every vector runs in genuine page context inside the fixture (see the
* note in sink-boundary.runner.js for why this must not move into page.evaluate); the spec only reads
* the recorded result after a short settle that lets async sinks (string setTimeout, script.src) land.
*
* COVERED: must be neutralized under DOMFortify, and must execute on the unprotected canary (which
* proves each vector is a real, working sink and that the detector sees it).
* BOUNDARY: outside the Trusted Types contract by design (function-handler assignment); documented,
* not guarded as a vulnerability.
*/
import { test, expect, type Page } from '@playwright/test';

const COVERED = [
'innerHTML',
'outerHTML',
'insertAdjacentHTML',
'createContextualFragment',
'template.innerHTML',
'eval',
'Function',
'setTimeout(string)',
'script.text',
'script.src',
'setAttribute-onclick',
];
const BOUNDARY = ['el.onclick = fn'];

interface Probe {
status: Record<string, unknown> | null;
fired: Record<string, boolean>;
matrix: { label: string; category: string; threw: boolean; msg: string }[];
}

async function probe(page: Page, fixture: string): Promise<Probe> {
await page.goto(`/test/fixtures/${fixture}`);
await page.waitForFunction(() => (window as unknown as { __matrixReady?: boolean }).__matrixReady === true, null, {
timeout: 5_000,
});
await page.waitForTimeout(300); // let async sinks (string setTimeout, script.src) settle
return page.evaluate(() => ({
status:
(
window as unknown as { DOMFortify?: { status?: () => Record<string, unknown> | null } }
).DOMFortify?.status?.() ?? null,
fired: (window as unknown as { __fired: Record<string, boolean> }).__fired,
matrix: (window as unknown as { __matrix: Probe['matrix'] }).__matrix,
}));
}

// --- Canary: on an unprotected page every covered vector must execute -----------------------------
test('canary: every covered sink executes on the unprotected page', async ({ page }) => {
const { fired } = await probe(page, 'sink-boundary-unprotected.html');
for (const label of COVERED) {
expect(fired[label], `${label} must execute when nothing is enforcing, or the test is meaningless`).toBe(true);
}
});

// --- Protected: every covered vector is neutralized -----------------------------------------------
test('every covered sink is neutralized under DOMFortify', async ({ page }) => {
const { status, fired } = await probe(page, 'sink-boundary.html');
// Only assert the guarantee where the engine actually enforces Trusted Types (matches the rest of
// the e2e suite); non-enforcing engine builds skip rather than fail.
test.skip(!status?.enforcementActive, 'engine build does not enforce Trusted Types natively');
expect(status?.protected, 'page should report protected').toBe(true);
for (const label of COVERED) {
expect(fired[label] ?? false, `${label} must be neutralized under enforcement`).toBe(false);
}
});

// --- Boundary: function-handler assignment is outside the contract and stays so --------------------
test('boundary sinks remain outside the Trusted Types contract (documented, not guarded)', async ({ page }) => {
const { status, fired } = await probe(page, 'sink-boundary.html');
test.skip(!status?.enforcementActive, 'engine build does not enforce Trusted Types natively');
for (const label of BOUNDARY) {
expect(
fired[label] ?? false,
`${label} sits outside Trusted Types; if this changes, re-evaluate the threat model and docs`,
).toBe(true);
}
});
12 changes: 12 additions & 0 deletions test/fixtures/sink-boundary-unprotected.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<!-- Canary baseline: no CSP, no DOMFortify, so Trusted Types enforcement is off and every covered
vector must EXECUTE here. That proves the vectors are real and the runner detects them; only
then is a "did not execute" on sink-boundary.html meaningful. -->
</head>
<body>
<div id="sb"></div>
<script src="/test/fixtures/sink-boundary.runner.js"></script>
</body>
</html>
17 changes: 17 additions & 0 deletions test/fixtures/sink-boundary.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<!-- No DEPLOY/EXPECT directives: deployment.spec.ts skips fixtures without them, so this boundary
fixture is owned solely by sink-boundary.spec.ts. Trusted Types on via meta, DOMFortify loaded
first so it claims the default policy. -->
<meta http-equiv="Content-Security-Policy"
content="require-trusted-types-for 'script'; trusted-types default dompurify;">
<script src="/node_modules/dompurify/dist/purify.min.js"></script>
<script>window.DOMFortifyConfig = {};</script>
<script src="/dist/fortify.js"></script>
</head>
<body>
<div id="sb"></div>
<script src="/test/fixtures/sink-boundary.runner.js"></script>
</body>
</html>
123 changes: 123 additions & 0 deletions test/fixtures/sink-boundary.runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Sink-boundary matrix runner (shared by the protected fixture and the unprotected canary).
*
* It runs every vector in GENUINE page context - this file executes as the page loads, NOT through
* Playwright's page.evaluate. That is deliberate and load-bearing: page.evaluate runs in a CDP context
* that bypasses Trusted Types enforcement for eval()/Function() specifically (innerHTML and setTimeout
* stay gated), so measuring those from page.evaluate falsely reports them as executed. Driving the
* sinks from the page itself is the only honest way to test eval/Function. Do not "simplify" this into
* page.evaluate.
*
* Each payload calls window.__H('<label>'); the spec reads window.__fired after a short settle so async
* sinks (string setTimeout, script.src) are captured too. window.__matrix records whether each sink
* threw (refused). The spec cross-references the two.
*/
(function () {
var host = document.getElementById('sb') || document.body;
var matrix = [];
window.__fired = {};
window.__H = function (label) {
window.__fired[label] = true;
};
// NOTE: labels are interpolated into injected handler strings, so they MUST stay quote-free.
function q(L) {
return "window.__H('" + L + "')";
}
function fresh() {
var c = document.createElement('div');
host.appendChild(c);
return c;
}
function vec(label, category, run) {
var threw = false,
msg = '';
try {
run(label);
} catch (e) {
threw = true;
msg = String((e && e.message) || e).slice(0, 90);
}
matrix.push({ label: label, category: category, threw: threw, msg: msg });
}

// HTML sinks: inject an inline handler as markup, then click it. Stripped by the sanitizer -> no run.
vec('innerHTML', 'html', function (L) {
var c = fresh();
c.innerHTML = '<div onclick="' + q(L) + '">x</div>';
var d = c.querySelector('div');
if (d) d.click();
});
vec('outerHTML', 'html', function (L) {
var t = fresh();
var s = document.createElement('span');
t.appendChild(s);
s.outerHTML = '<b onclick="' + q(L) + '">x</b>';
var b = t.querySelector('b');
if (b) b.click();
});
vec('insertAdjacentHTML', 'html', function (L) {
var c = fresh();
c.insertAdjacentHTML('beforeend', '<i onclick="' + q(L) + '">x</i>');
var i = c.querySelector('i');
if (i) i.click();
});
vec('createContextualFragment', 'html', function (L) {
var c = fresh();
var r = document.createRange();
r.selectNode(c);
c.appendChild(r.createContextualFragment('<u onclick="' + q(L) + '">x</u>'));
var u = c.querySelector('u');
if (u) u.click();
});
vec('template.innerHTML', 'html', function (L) {
var c = fresh();
var t = document.createElement('template');
t.innerHTML = '<s onclick="' + q(L) + '">x</s>';
c.appendChild(t.content.cloneNode(true));
var s = c.querySelector('s');
if (s) s.click();
});

// String-to-code script sinks: refused by createScript / createScriptURL (return null -> sink throws).
vec('eval', 'script', function (L) {
eval(q(L));
});
vec('Function', 'script', function (L) {
Function(q(L))();
});
vec('setTimeout(string)', 'script', function (L) {
setTimeout(q(L), 0);
});
vec('script.text', 'script', function (L) {
var s = document.createElement('script');
s.text = q(L);
fresh().appendChild(s);
});
vec('script.src', 'script', function (L) {
var s = document.createElement('script');
s.src = 'data:text/javascript,' + encodeURIComponent(q(L));
fresh().appendChild(s);
});

// Event-handler attribute set via setAttribute: also a TrustedScript sink, also refused.
vec('setAttribute-onclick', 'attr', function (L) {
var b = document.createElement('button');
fresh().appendChild(b);
b.setAttribute('onclick', q(L));
b.click();
});

// Boundary marker: assigning a FUNCTION to a handler property is not a string sink and not reachable
// by markup injection; Trusted Types does not see it. Documented as outside the contract, not a bug.
vec('el.onclick = fn', 'boundary', function (L) {
var b = document.createElement('button');
fresh().appendChild(b);
b.onclick = function () {
window.__H(L);
};
b.click();
});

window.__matrix = matrix;
window.__matrixReady = true;
})();