Repository navigation
Risks and Footguns
DOMFortify is deliberately small and honest, but there are sharp edges. None of these are obscure; they are the things that decide whether you are actually protected.
The default policy is winner-takes-all. The first code to register it owns every DOM write for the life of the page. So DOMFortify has to run first, inline in <head>, before anything an attacker could influence. If hostile code registers default before you, DOMFortify will not evict it and will not vouch for it, and you are worse off than if DOMFortify were not there. Load it first, and check status().defaultPolicyOwned.
'allow-duplicates' in the trusted-types directive lets more than one policy share a name and removes the lock that makes the default policy a single owner. With it, DOMFortify can create its policy and still not be the active one. Leave it out. If status() reports the policy was created but is not active, this is usually why.
INJECT_META can turn enforcement on without you setting a header, which is genuinely useful, but a <meta> CSP is only honored when the parser inserts it. That means it only works if DOMFortify runs during the initial parse (inline, early in <head>) and only for content parsed afterwards. A header or a hand-placed <meta> is sturdier. Whichever you use, do not assume, check status().enforcementActive.
This is a feature, but it surprises people. If enforcement is on and the sanitizer is missing or throws, the HTML sinks throw, and the page may break. That is the safe direction; the alternative is leaking raw markup. The fix is not to loosen DOMFortify, it is to make the sanitizer reliable: bundle it, pin it with SRI, and do not depend on a CDN being up.
DOMFortify only sees 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 so is setAttribute('onclick', ...), because the browser treats event-handler content attributes as a TrustedScript sink.
What stays open is only the residue no Trusted Types policy is consulted for, and it is your responsibility:
- Assigning a function to a handler property:
el.onclick = fn. This already presupposes script execution on the page and is not reachable by markup injection. - Assigning a
javascript:URL straight to a property:a.href = "javascript:...",location = ...,window.open(...). - Style sinks:
el.style,style="...", CSS-based injection and exfiltration.
Close those definitively with a real CSP next to the Trusted Types one, for example script-src 'self'; object-src 'none'; base-uri 'none' (no 'unsafe-inline'). DOMFortify pairs with a good CSP; it does not replace one. The sink-boundary e2e suite pins this boundary on every run: it asserts that each covered sink executes on an unprotected page and is neutralized under DOMFortify, and that the residue above is documented rather than guarded.
On a URL matched by EXCLUDE, DOMFortify installs nothing, no policy and no meta. It deliberately does not install a passthrough, because that would be a silent hole. Under enforcement delivered globally (a site-wide header), an excluded page still has enforcement on but no default policy, which means sinks throw. Either way, an excluded page is unprotected by DOMFortify and is your responsibility. Use EXCLUDE sparingly and know what it means.
Everything the config decides - EXCLUDE, the SANITIZER, SANITIZER_CONFIG, the ALLOW_SCRIPT hooks - is read once at the first init() and then fixed: init() is one-shot, the resolved values live in the policy's closures, and the browser will not let the default policy be replaced. Mutating window.DOMFortifyConfig afterwards does nothing, and reads are own-key only, so prototype pollution cannot inject a value. There is nothing left to lock after init, and a "lock the config" switch would add no boundary.
That leaves one window: before init. Whoever runs first sets the config, so attacker-influenced code running ahead of DOMFortify could plant an EXCLUDE that matches the page, or a passthrough SANITIZER, and neutralise it. This is not a separate hole; it is the same load-order precondition as the default policy. Code that can set your config first can already claim the policy itself or patch the native sinks. Run first, inline in <head>.
How bad a tampered config gets depends on where enforcement comes from, which is the concrete reason to prefer a header:
- Enforcement from a CSP header: an injected
EXCLUDEleaves DOMFortify installing no policy, but enforcement is still on, so the HTML sinks have nodefaultpolicy to satisfy and they throw. Raw markup never lands. Worst case is a broken page, not XSS. - Enforcement from
INJECT_METAonly: an injectedEXCLUDEmeans no meta is written, enforcement never turns on, and raw strings flow. That is a real bypass.
So the answer to a tampered config is not a config lock; it is delivering enforcement out of band with a header, which decouples the kill switch from DOMFortify's own config.
ALLOW_SCRIPT and ALLOW_SCRIPT_URL let you opt specific code or URLs through the script sinks. Every value you allow is code you are choosing to run. Keep the hooks as narrow as possible (exact-match, not pattern-match where you can avoid it), and remember that a hook returning attacker-influenced input reintroduces exactly the XSS you are trying to stop.
DOMFortify forwards SANITIZER_CONFIG to the sanitizer verbatim. A loose DOMPurify config (allowing <script>, dangerous attributes, or unsafe URI schemes) will let those through, and DOMFortify will faithfully deliver the result. Keep the sanitizer config tight, and review it as part of your threat model, not as an afterthought.
DOMFortify does not add its own HTML parsing or mXSS defenses; it relies on the sanitizer. A bypass in DOMPurify is a bypass through DOMFortify. Keep the sanitizer current. This is a strength (you get DOMPurify's mature defenses) and a dependency (you get its bugs too).
The default policy hands the sink a sanitized string, which the browser then parses again in the sink's own context. That serialize/re-parse step is exactly where mutation XSS lives. DOMFortify adds no parsing of its own; it relies on the sanitizer's mXSS hardening, which for DOMPurify is extensive and actively maintained. Swap in a weaker sanitizer, or a permissive DOMPurify config, and that hardening is gone. A browser-native sink-level sanitizer (the direction of Mozilla's "Trusted or Sanitized HTML" proposal) avoids the round trip entirely; until that ships, keep DOMPurify current.
The recurring theme: do not assume you are protected, confirm it. status().protected is true only when enforcement is on, the policy is owned, and the sanitizer is ready. When it is false, status().reason tells you which of those failed. Wire ON_VIOLATION to telemetry and watch in report-only mode before you rely on enforcement.