-
Notifications
You must be signed in to change notification settings - Fork 1
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. These stay open and are your responsibility:
- Inline event handler attributes:
onclick="...",onerror="...", and so on. - Style sinks:
el.style,style="...", CSS-based injection. - URL properties and navigation:
a.href = "javascript:...",location = ...,window.open(...).
Close the handler class with a script-src that drops 'unsafe-inline'. DOMFortify pairs with a good CSP; it does not replace one.
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.
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 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.