Guide · Frontend
DOMPurify's October 2026 Bypasses Explained: Audit Your Sanitiser Config
18 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- The DOMPurify bypass news of October 2026 is two advisories that only affect apps using non-default options. The default sanitize(dirty) call was not affected by either.
- DOMPurify is a library that cleans untrusted HTML so it cannot run script. Its options let you change what it keeps and whether it returns a string or DOM nodes, the browser's live tree of page elements.
- One bug needs SAFE_FOR_TEMPLATES, a mode that strips placeholders like {{ name }}, plus DOM output and an input node with a shadow root, a web component's hidden sub-tree. The other needs IN_PLACE mode plus a hook, your own callback, that removes nodes.
- Upgrade to dompurify 3.4.17, released on 12 October 2026. The maintainer rates both Low, while the GitHub Advisory Database rates them 9.2 Critical and 7.6 High.
- Beginners can stop after the section on who is affected. Engineers get a tested table of every option that weakens the defaults, a config audit script and the exact code change in 3.4.17.
What goes wrong when a safe HTML sanitiser runs with a risky config?
It stops promising what you think it promises. The DOMPurify bypass advisories of October 2026 did not break the default setup; they broke two optional modes that some apps switch on.
A sanitiser is a library that takes untrusted HTML and removes anything that could run script. Cross-site scripting, or XSS, is the attack it stops: an attacker's script running inside your page, with your user's login.
Picture a common setup. A web app lets customers post rich-text comments, and those go through DOMPurify with its default settings. The setup is made up, but each config in it is one the October advisories describe.
Two other screens in the same app are different. A help-centre widget is a web component: it sanitises its host element in place with SAFE_FOR_TEMPLATES, then a template engine fills in placeholders in its shadow root. An editor preview sanitises a live node in place, and a hook drops a whole figure wrapper when its image is not allowed.
The comments screen was never at risk. The help-centre widget and the editor preview were, on any version up to 3.4.16. Nobody changed the library; somebody changed its options, for good reasons.
Which words do you need before reading the DOMPurify advisories?
You need six words. Each one comes with a plain meaning and where it shows up in the running example.
- Config option: a setting you pass as the second argument, like sanitize(dirty, { IN_PLACE: true }). The help-centre widget and the editor preview each pass one.
- Allow-list: the list of tags and attributes DOMPurify keeps. Everything else is removed. The comments screen uses the built-in allow-list.
- Hook: your own function that DOMPurify calls for each element or attribute while it works. The editor preview's figure-dropping rule is a hook.
- String output vs DOM output: by default you get back an HTML string. RETURN_DOM, RETURN_DOM_FRAGMENT and IN_PLACE give you DOM nodes instead.
- Shadow root: a hidden sub-tree attached to an element, used by web components to keep their markup separate. The help-centre widget keeps its content there.
- Template engine: a library that replaces placeholders such as {{ name }} or ${ total } with values. If an attacker can plant a placeholder, the engine may run it.
A security guard at a building entrance is a useful picture. The guard holds a guest list, which is the allow-list. Config options are extra instructions you give the guard, such as checking people already inside instead of at the door, which is IN_PLACE.
Where the picture breaks: a guard can judge intent, and DOMPurify cannot. It only compares names and values against its lists. If your instructions say to wave something through, it obeys.
What is the DOMPurify bypass fixed in 3.4.17?
It is two separate holes, both closed in dompurify 3.4.17. Each lets attacker markup survive sanitising, but only when the app has turned on specific options.
| GHSA-cg2g-xh64-v4q7 | GHSA-mrv2-3pqr-j5jr | |
|---|---|---|
| Short name | SAFE_FOR_TEMPLATES and shadow roots | IN_PLACE and node-removing hooks |
| Options you must have set | SAFE_FOR_TEMPLATES plus RETURN_DOM, RETURN_DOM_FRAGMENT or IN_PLACE | IN_PLACE plus a hook that removes a parent, ancestor or sibling |
| Other condition | You pass a DOM node with an attached shadow root, and a template engine reads it | Attacker markup is already in the live node you sanitise |
| What survives | A template expression split across text nodes | Attributes, including event handlers, on the removed elements |
| Affected versions | 3.4.4 to 3.4.16 | 3.4.16 and earlier |
| Maintainer severity | Low | Low |
| GitHub Advisory Database | 9.2 Critical (CVSS 4.0) | 7.6 High (CVSS 4.0) |
| Fixed in | 3.4.17 | 3.4.17 |
Both advisories went up on the DOMPurify repository on 12 October 2026, the same day 3.4.17 shipped. As of 28 October 2026, neither has a CVE number. The second one is a follow-up to an earlier fix, GHSA-p98j-92pf-mc4p, shipped in 3.4.16.
Why does the maintainer say Low when GitHub says Critical?
Because they are measuring different things. The maintainer rates who supplies the dangerous ingredient; the database score rates how bad the result is once every condition holds.
DOMPurify's security policy sorts reports into kinds. Input only means default or reasonable settings fall to attacker markup alone; that is a genuine bypass. Input plus an application choice means the app first configured something risky; those are hardening bugs, and both advisories here sit in that group.
CVSS, the Common Vulnerability Scoring System, is the 0 to 10 scale databases use. Version 4.0 has an Attack Requirements field, and both scores set it to Present. That flag records the special setup, but high confidentiality and integrity impact still pushes the number up.
So both statements are true. The library's default promise held. An app that did use these modes could still get XSS, or template injection on a server, and that is what 9.2 describes.
Who is affected by the October 2026 DOMPurify advisories?
You are affected only if your code turns on the options in the advisory table and runs a version up to 3.4.16. Plain sanitize(dirty) returning a string is outside both advisories.
Follow the diagram from the top. Most apps leave at the first question, like the comments screen in our example.
Does either advisory apply to your app?
Drag to exploreNotice that every path to an exposed box needs two non-default choices. String output with default options exits at the first question.
Diagram connections:
- Your sanitize() call to Do you get DOM back?
- Do you get DOM back? to Not in scope (No, a string)
- Do you get DOM back? to SAFE_FOR_TEMPLATES on? (Yes)
- SAFE_FOR_TEMPLATES on? to Exposed to cg2g (Yes)
- SAFE_FOR_TEMPLATES on? to IN_PLACE with a hook? (No)
- IN_PLACE with a hook? to Exposed to mrv2 (Yes)
- IN_PLACE with a hook? to Not exposed to these two (No)
- Exposed to cg2g to IN_PLACE with a hook? (Also check hooks)
- Your sanitize() call: Find every call site in your code
- Do you get DOM back?: IN_PLACE, RETURN_DOM or RETURN_DOM_FRAGMENT
- Not in scope: String output: neither advisory applies
- SAFE_FOR_TEMPLATES on?: And a node with an attached shadow root
- IN_PLACE with a hook?: A hook that removes or moves other nodes
- Exposed to cg2g: Upgrade to 3.4.17 before anything else
- Exposed to mrv2: Upgrade to 3.4.17 before anything else
- Not exposed to these two: Still upgrade: 3.4.17 has other hardening
In the running example, the help-centre widget takes the cg2g path. The editor preview takes the mrv2 path. The comments screen stops at Not in scope.
How did SAFE_FOR_TEMPLATES miss expressions inside shadow roots?
Its final clean-up pass reached template elements but not shadow roots. So an expression that only formed late, after DOMPurify removed something, survived in shadow root text.
SAFE_FOR_TEMPLATES works in two layers. First, each text node and attribute value is checked on its own for {{, ${ or ERB-style tags, the percent-sign markers of Ruby templates. Matching text is replaced with a space. Then a final pass checks the joined result. For string output it checks the whole HTML string. For DOM output it runs a post-walk pass, a second trip over the tree after the main visit to every node.
The trick in the advisory splits one expression across two text nodes, with a disallowed element between them. Neither half matches on its own. When DOMPurify removes the element, the halves become neighbours, and only the post-walk pass could catch the joined text.
How a split placeholder slipped through
Drag to exploreThe first check passes both halves. Only the final pass sees them joined, and up to 3.4.16 that pass never entered shadow roots.
Diagram connections:
- Shadow root text to Per-node check
- Per-node check to Element removed
- Element removed to Which version?
- Which version? to 3.4.16: skipped (3.4.4 to 3.4.16)
- Which version? to 3.4.17: scrubbed (3.4.17)
- 3.4.16: skipped to Template engine (Expression reaches it)
- Shadow root text: Half one, a disallowed element, half two
- Per-node check: Each half alone does not match
- Element removed: Halves are now next to each other
- Which version?: Decides if the final pass enters shadow roots
- 3.4.16: skipped: Joined expression survives
- 3.4.17: scrubbed: Joined text is checked and stripped
- Template engine: Runs any expression it finds
We checked this with harmless text, not a payload. On 3.4.16, a shadow root holding "Total: $", a disallowed element and "{ greeting }" came back as "Total: ${ greeting }". On 3.4.17 it came back as "Total:" followed by spaces.
The same split text outside a shadow root was cleaned on both versions. So was the same markup passed as a string, because DOMPurify's own parser never builds shadow roots from strings.
How could an IN_PLACE hook leave event handlers on removed elements?
According to the advisory, the 3.4.16 fix only cleaned up a node when the hook removed that same node. If a hook removed the node's parent or a later sibling, the removed elements were never walked, so their attributes stayed.
IN_PLACE means DOMPurify edits the node you pass instead of building a copy. That node is live. An image inside it may already be loading, and its load or error handler can fire after sanitize() returns, even if the image is no longer on the page.
In the editor preview, the hook removes the figure wrapper when it meets the image. The figcaption inside that figure has not been visited yet. We gave it a harmless non-allowed attribute, foo.
Pause and predict: after sanitize() returns, does the detached figcaption still carry foo? On 3.4.16 it did. On 3.4.17 it came back without foo. A hook that removed the next sibling gave the same split.
What did DOMPurify 3.4.17 change in the code?
Two additions close the two holes: one line for shadow roots and a new clean-up stage for IN_PLACE. We compared the published dist/purify.cjs.js files of 3.4.16 and 3.4.17 line by line.
The shadow root fix
The internal function that sanitises a shadow root now ends with one new step. When SAFE_FOR_TEMPLATES is on, it calls _scrubOwnCharacterData on that fragment, which joins neighbouring text and strips expressions again.
The scrub is deliberately not recursive there. Each nested shadow root and template content is already handled by its own call, and the source comment says rescrubbing per ancestor would be quadratic.
The IN_PLACE fix
Before the walk, 3.4.17 records every node under the root, including open shadow roots. After the walk, any recorded element no longer under the root has every non-allowed attribute stripped. This happens before sanitize() returns or throws.
It also handles nodes a hook moves backwards. Recorded nodes still in the tree but never visited get the normal treatment, hooks included. If the tree keeps changing for 8 rounds, DOMPurify strips the root bare and throws a TypeError.
Smaller changes in the same release
- A third advisory, GHSA-c6gr-qvv4-5v3f, rated Low, fixes a check that was skipped when PARSER_MEDIA_TYPE is application/xhtml+xml.
- The public isValidAttribute() helper now applies the same value checks as sanitize(), so it cannot approve a value sanitize() would strip.
- The README now says DOMPurify only covers open shadow roots. Nodes built with setHTMLUnsafe() can carry closed ones it cannot see, so it advises passing untrusted markup as a string.
Which DOMPurify options weaken the default guarantees?
Any option that widens the allow-list, relaxes a check or hands DOMPurify a live tree. The project's threat model page asks you to require a reason for each flag on its list.
We ran the rows with a result of our own on dompurify 3.4.16 and 3.4.17 with jsdom 26.1.0, using harmless input; both versions matched. Rows that quote the README were not run.
| Option | Default | What changes when you set it | What we saw, or what the README says |
|---|---|---|---|
| SAFE_FOR_TEMPLATES | false | Strips placeholders and turns off data attributes | Text from the start to }} became a space |
| IN_PLACE | false | Edits your live node instead of a copy | Returned the same node object |
| RETURN_DOM, RETURN_DOM_FRAGMENT | false | Returns a body element or a fragment | Wiki safe recipe with string input; DOM node input needs care |
| ADD_ATTR, ADD_TAGS | not set | Adds names to the allow-list | foo="bar" kept only with ADD_ATTR |
| ALLOW_UNKNOWN_PROTOCOLS | false | Lets any URL scheme through | myapp://open/42 kept only with it on |
| ALLOWED_URI_REGEXP | built-in | Replaces the URL scheme check | Not tested: depends on your pattern |
| SAFE_FOR_XML | true | Turning it off drops checks for comment and rawtext tricks | README: only for HTML-only input |
| SANITIZE_DOM | true | Turning it off drops protection against DOM clobbering, where id or name attributes shadow page properties | README: minor XSS risks |
| setConfig() | none | Per-call options are ignored until clearConfig() | Per-call ALLOWED_TAGS was ignored |
Hooks deserve their own line. The README says afterSanitize hooks run after validation and are never re-checked. Calling sanitize() from inside a hook can swap the config mid-run.
How do you audit your own DOMPurify config?
Find every sanitize() call, list the options and hooks each one uses, and check each against the table. Then upgrade and prove the new version is the one that runs.
Find every call site
Search the code for sanitize(, setConfig( and addHook(. Include wrappers such as isomorphic-dompurify and any library that bundles DOMPurify.
Write down each config
For each call, note the options object, the input type, string or DOM node, and the output type.
Run the audit helper
Paste each options object into the helper below. Treat STOP lines as bugs and REVIEW lines as questions for the code owner.
Read every hook
Mark hooks that remove, move or add nodes, and hooks that set forceKeepAttr. With IN_PLACE, these need 3.4.17.
Prefer the safe recipes
Where you can, switch to string input with string output, USE_PROFILES html only, or a tight ALLOWED_TAGS list.
Upgrade and re-check
Install 3.4.17, run npm audit and confirm the version at run time.
Here is the helper. It is a plain checklist in code, not a security scanner, and it only knows the options covered in this guide.
function auditDomPurifyConfig(cfg = {}, usesHooks = false) {
const notes = [];
const flag = (level, msg) => notes.push(level + ': ' + msg);
const widen = ['ADD_TAGS', 'ADD_ATTR', 'ADD_URI_SAFE_ATTR', 'ADD_DATA_URI_TAGS', 'CUSTOM_ELEMENT_HANDLING'];
for (const key of widen) {
if (key in cfg) flag('REVIEW', key + ' widens the allow-list; write down why');
}
const attrs = Array.isArray(cfg.ADD_ATTR) ? cfg.ADD_ATTR : [];
if (attrs.some((a) => /^on/i.test(a))) flag('STOP', 'ADD_ATTR allows an on* event handler');
if (cfg.ALLOW_UNKNOWN_PROTOCOLS === true) flag('STOP', 'ALLOW_UNKNOWN_PROTOCOLS skips the URL scheme check');
if ('ALLOWED_URI_REGEXP' in cfg) flag('REVIEW', 'custom ALLOWED_URI_REGEXP replaces the default URL check');
if (cfg.SAFE_FOR_XML === false) flag('STOP', 'SAFE_FOR_XML is off; the README says only for HTML-only input');
if (cfg.SANITIZE_DOM === false) flag('STOP', 'SANITIZE_DOM is off; DOM clobbering protection is gone');
if (cfg.WHOLE_DOCUMENT === true) flag('REVIEW', 'WHOLE_DOCUMENT keeps html, head and body');
const domOut = cfg.RETURN_DOM === true || cfg.RETURN_DOM_FRAGMENT === true || cfg.IN_PLACE === true;
if (cfg.RETURN_DOM === true || cfg.RETURN_DOM_FRAGMENT === true) flag('REVIEW', 'DOM output: pass strings in, not live nodes');
if (cfg.SAFE_FOR_TEMPLATES === true) flag('REVIEW', 'SAFE_FOR_TEMPLATES: the README says not for production');
if (cfg.SAFE_FOR_TEMPLATES === true && domOut) flag('STOP', 'SAFE_FOR_TEMPLATES with DOM output: needs 3.4.17+');
if (cfg.IN_PLACE === true) flag('REVIEW', 'IN_PLACE sanitizes a live node; needs 3.4.17+ if hooks remove nodes');
if (cfg.IN_PLACE === true && usesHooks) flag('STOP', 'IN_PLACE plus hooks: check every hook that removes or moves nodes');
if (cfg.PARSER_MEDIA_TYPE === 'application/xhtml+xml') flag('REVIEW', 'XHTML parser mode: covered by a separate 3.4.17 fix');
if (notes.length === 0) notes.push('OK: no option here weakens the defaults');
return notes;
}
console.log(auditDomPurifyConfig({ ALLOWED_TAGS: ['p', 'b', 'i', 'a'], ALLOWED_ATTR: ['href'] }));
console.log(auditDomPurifyConfig({ SAFE_FOR_TEMPLATES: true, IN_PLACE: true }));
console.log(auditDomPurifyConfig({ IN_PLACE: true, ADD_ATTR: ['target'] }, true));Running it with Node.js 22 prints three arrays. The comments config prints OK. The help-centre config prints two REVIEW lines and one STOP line, for SAFE_FOR_TEMPLATES with DOM output. The editor preview prints two REVIEW lines and one STOP line, for IN_PLACE with hooks.
How do you check that you are on DOMPurify 3.4.17?
Ask npm which copies you have, then confirm at run time. Editors and diagram libraries often depend on DOMPurify, so check the whole tree, not just your package.json.
# 1. Every copy of DOMPurify in your dependency tree, including nested ones
npm ls dompurify --all
# 2. Upgrade the direct dependency to the fixed release
npm install [email protected]
# 3. Ask npm whether any known DOMPurify advisory still applies
npm auditAfter the upgrade, run the first command again: every line should show [email protected]. If a nested copy is older, update the package that brings it in. npm ls cannot see a copy that another package has bundled into its own built files, which is why the run-time check matters. On 3.4.16, npm audit listed three DOMPurify advisories, one critical, one high and one low. On 3.4.17 it printed found 0 vulnerabilities.
In the browser, DOMPurify.version returns the version string, for example "3.4.17". In Node.js, require('dompurify/package.json') fails with ERR_PACKAGE_PATH_NOT_EXPORTED, so use npm ls or DOMPurify.version instead.
How do you explain the DOMPurify bypass in a system design interview?
Use it as a story about configuration risk, not about one library being broken. Interviewers want to hear that you know where the guarantee ends.
Name the job
A sanitiser removes anything that can run from untrusted HTML before it reaches an HTML sink such as innerHTML.
Name the boundary
The strongest guarantee is for default or narrowing options. Every widening option or hook moves part of the job back to your team.
Give the example
In October 2026, two DOMPurify holes needed SAFE_FOR_TEMPLATES with DOM output, or IN_PLACE with a node-removing hook. Version 3.4.17 fixed both.
Explain the severity gap
The maintainer rated them Low because the app must opt in. The database rated them 9.2 and 7.6 because the impact can be XSS.
Add layers
Pair the sanitiser with a Content Security Policy, a header that limits which scripts run. Add Trusted Types, a browser check on HTML sinks, and keep template engines away from user HTML.
Close with process
Review config in code review, run npm audit in CI, and keep one shared sanitise wrapper so options cannot drift.
If you have time, mention the over-stripping detail. It shows you tested the behaviour, not just read the headline.
Questions people ask
- Is DOMPurify broken after the October 2026 advisories?
- No. Both advisories need non-default options that the app turns on, and the default sanitize() call returning a string was outside both. Version 3.4.17 fixes the two holes. The lesson is to review the options you add, because each one changes what the sanitiser promises.
- Do I need to upgrade if I use the default config?
- Yes, upgrade anyway, but there is no emergency from these two advisories. Version 3.4.17 also carries other hardening, and dependency scanners will keep flagging 3.4.16. Upgrading removes the noise and keeps you current for the next fix.
- Why is there no CVE number for these DOMPurify advisories?
- As of 28 October 2026, both advisory pages say no known CVE. They use GitHub Security Advisory IDs instead, GHSA-cg2g-xh64-v4q7 and GHSA-mrv2-3pqr-j5jr. Scanners such as npm audit match on these IDs, so you do not need a CVE to find affected installs.
- Does React or another framework protect me from this?
- Only for text it renders itself. When you insert sanitised HTML with dangerouslySetInnerHTML or innerHTML, the framework trusts it, so DOMPurify's options decide what gets through. The advisories are about those options, not about framework escaping.
- Is SAFE_FOR_TEMPLATES safe to use after 3.4.17?
- It is fixed for the reported case, but the README still says it is not recommended for production. It also removes more text than the placeholder and turns off data attributes. The safer design is to never pass user HTML through a template engine at all.
- Is IN_PLACE mode safe to use after 3.4.17?
- It is much safer: 3.4.17 cleans every element that left the tree during the call. One limit stays: DOMPurify cannot undo browser behaviour that already ran before sanitize() was called. Sanitise untrusted trees before you attach them to the page.
- Am I affected if I use DOMPurify 2.x?
- Yes, for the IN_PLACE advisory: it lists every version up to 3.4.16 as affected, so 2.x is included. The shadow root advisory starts at 3.4.4. As of 28 October 2026, the newest 2.x release is 2.5.9 from March 2026, so the fix means moving to 3.4.17.
Sources and further reading
- GHSA-cg2g-xh64-v4q7: SAFE_FOR_TEMPLATES bypass in attached shadow roots (DOMPurify repository advisory)
- GHSA-mrv2-3pqr-j5jr: IN_PLACE hook that removes a parent or sibling (DOMPurify repository advisory)
- OSV record for GHSA-cg2g-xh64-v4q7 with the GitHub Advisory Database CVSS 4.0 score
- OSV record for GHSA-mrv2-3pqr-j5jr with the GitHub Advisory Database CVSS 4.0 score
- DOMPurify 3.4.17 release notes (12 October 2026)
- GHSA-p98j-92pf-mc4p: the earlier IN_PLACE hook fix in 3.4.16
- DOMPurify security policy: how reports are classified
- DOMPurify wiki: Security Goals and Threat Model