Repository navigation
fix(deps): update dependency dompurify to v3.4.17 [security] - #4393
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
Contributor
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
3.4.16→3.4.17DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml
GHSA-c6gr-qvv4-5v3f
More information
Details
Summary
DOMPurify drops
attributeNameattributes whose value containshref, so an allowed<animate>or<set>can't rewrite a link's href after sanitizing. The check compares the attribute name aftertransformCaseFunc, and that function doesn't lowercase in XHTML mode. In XHTML the attribute is spelledattributeName, so the comparison with"attributename"never matches and the guard does nothing.An app that uses
PARSER_MEDIA_TYPE: 'application/xhtml+xml'and allowsanimateandattributeNamewill keep<animate attributeName="href" values="x;javascript:alert(1)">. Clicking the parent link then runs the script.This needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config.
Details
In
_sanitizeAttributes(dist/purify.js line 2216 in 3.4.16):transformCaseFuncisstringToLowerCasefor text/html but the identity function for application/xhtml+xml, because XML names are case-sensitive. The XML parser keeps SVG's real nameattributeName, so in XHTML modelcNameisattributeNameand the branch never runs.People using XHTML mode have to list SVG attributes in camelCase. The built-in attribute lists are lowercase and won't match, so
ALLOWED_ATTR/ADD_ATTR: ['attributeName', ...]is the config you'd naturally write here.The value also passes the URI check.
IS_ALLOWED_URIonly checks the start of the string, andx;javascript:alert(1)looks like a relative value. SMIL readsvaluesas a;list and, withfill="freeze", the last entry ends up as the effective href of the parent<a>.In text/html mode the same allowlist is handled correctly. The name is lowercased and
attributeNameis removed.This is close to the XHTML case-sensitivity hardening in 3.4.14/3.4.15 (Attack Classes & Bypass History §22): a check written for lowercase names that a case-preserved name gets past.
Proof of concept
Tested with DOMPurify 3.4.16 in Chrome 153 (headless shell). Also reproduced on 3.3.3, 3.3.1 and 3.2.7. Not confirmed for 2.x.
Config:
{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] }behaves the same.Input:
Steps:
poc.xhtmlnext topurify.js(3.4.16) and open it in Chrome. The.xhtmlextension makes Chrome load it as application/xhtml+xml, so nothing moves between HTML and XHTML.attributeName="href"andvalues="x;javascript:alert(1)".document.getElementById('q').href.animValreturns"javascript:alert(1)".alert(1)runs.As a control, remove
PARSER_MEDIA_TYPEand change the allowlist entry toattributename. The output then losesattributeName,href.animValis"", and clicking does nothing. The same XHTML-mode output also runs when inserted into a normal text/html page.An automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below.
poc.sh
Impact
Stored or reflected XSS on a click. It affects apps that sanitize SVG in XHTML mode and allow
animate(orset) withattributeName, for example to keep simple SVG animations. Script runs in the app's origin when a user clicks the sanitized link. Default configs and text/html mode aren't affected.Severity
Low
References
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)
GHSA-mrv2-3pqr-j5jr
More information
Details
Summary
The 3.4.16 fix for GHSA-p98j-92pf-mc4p only covers a hook that removes the node it was called for. If an
IN_PLACEhook removes a different node, like the current node's parent or a sibling that has not been visited yet, that subtree is never walked or neutralized. Event handlers in it stay on the elements, and an<img onerror>that was already loading fires aftersanitize()returns.The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In IN_PLACE mode their subtree is neutralized inline, so an
<img onload>that was already loading when you built the live tree cannot fire aftersanitize()returns." That does not hold when the detached node is not the hook's own node.Tested versions
jsdom for the matrix below, and Chromium (Playwright) for the event actually firing.
Details
src/purify.tsline 2134 (dist/purify.cjs.jsline 2009 in 3.4.16),_handleHookDetachedNode():It only checks whether
currentNodelost its parent. When a hook removescurrentNode.parentNode(or any ancestor),currentNodestill has a parent, so nothing happens. The rest of the removed subtree is no longer reachable by the walker, and it is not inDOMPurify.removed, so the post walk pass does not see it either. The same happens when a hook removes a later sibling. This applies to all five element and attribute hooks.PoC (jsdom)
Output on 3.4.16:
In
selfmode the walker still reaches the second image and stripsonerror. In the other two modes the second image is detached with its handler still set. The same result is achieved with each ofbeforeSanitizeElements,uponSanitizeElement,afterSanitizeElements,beforeSanitizeAttributesandafterSanitizeAttributes.PoC (browser)
The same thing in a real page, served from 127.0.0.1 and opened in Chromium:
Result on 3.4.16:
The returned tree is clean in both cases, but the handler on the detached image runs in the page for
parent.Impact
An app that uses
IN_PLACEwith a hook that removes a wrapper or a nearby element (for example "drop the whole<figure>if its image is not allowed") gets DOM XSS from attacker markup aftersanitize()returns. It needs that kind of hook, so it falls under "input plus an application choice" in SECURITY.md, but it breaks the guarantee the README gives for hook detached nodes and is the same pattern as GHSA-55q2-fjhq-7xh7 and GHSA-p98j-92pf-mc4p.Suggested fix
Instead of only checking
currentNode, check after each hook call whether anything outside the current node was detached. Two simple options:IN_PLACEmode, remember the next node the walker will visit and the current node's ancestors; after the hook, neutralize any of them that are no longer attached underrootroot(for example by collecting the elements before the walk)Severity
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
GHSA-cg2g-xh64-v4q7
More information
Details
Summary
When DOMPurify is configured with
SAFE_FOR_TEMPLATES: truetogether with a DOM-returning mode (RETURN_DOM: true,RETURN_DOM_FRAGMENT: true, orIN_PLACE: true), template expressions such as{{...}},${...}, and<%...%>survive sanitization inside the text content of an attached shadow root. The post-walk safety net_scrubTemplateExpressions(src/purify.ts:1717) was extended to recurse into<template>.content, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (theIN_PLACEand shadow-awareRETURN_DOMsurfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate.Root Cause
SAFE_FOR_TEMPLATESneutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (_stripTemplateExpressions, applied in_sanitizeElementsatsrc/purify.ts:2246), and a final post-walk pass (_scrubTemplateExpressions,src/purify.ts:1717) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content.Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.
Impact
An attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable
SAFE_FOR_TEMPLATESand a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically withattachShadow: in engines that support declarative shadow DOM, attacker-supplied markup containing<template shadowrootmode="open">parsed throughsetHTMLUnsafeorDocument.parseHTMLUnsafeproduces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context.Proof of Concept
Build a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with
SAFE_FOR_TEMPLATES:A downstream template evaluator reading
shadowRoot.textContent(for examplenew Function('return' + content + '')()or any${...}/{{...}}engine) then evaluates the fully formed expression.Attack Chain
DOMPurify.sanitize(node, cfg)(src/purify.ts:2854) withSAFE_FOR_TEMPLATES: trueandIN_PLACE: true(or a shadow-awareRETURN_DOM), passing a DOM node that already carries an attached shadow root.$,{alert(document.domain)}) is a valid, individually benign text node._sanitizeAttachedShadowRoots(src/purify.ts:2977) feeds the shadow root into_sanitizeShadowDOM(src/purify.ts:2710), which strips expressions per text node (src/purify.ts:2246) and removes the disallowed separator elements, leaving the fragments adjacent._scrubTemplateExpressions(src/purify.ts:3131-3132) is the only pass that would catch the merged expression, but it never recurses into shadow roots (src/purify.ts:1717-1751recurses into<template>.contentonly).shadowRoot.textContent/shadowRoot.innerHTML, reconstituting the complete expression.Bypass Evidence
The relevant protection is
SAFE_FOR_TEMPLATES, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to<template>.contentunder CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by addingquerySelectorAll('template')recursion atsrc/purify.ts:1743-1750. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of_scrubTemplateExpressions(src/purify.ts:1717,1747,3132,3141) shows none receive a shadow root, and_sanitizeShadowDOM(src/purify.ts:2710) never calls_scrubTemplateExpressionsornormalize(). The per-node strip cannot compensate:MUSTACHE_EXPR(src/regexp.ts:3) requires{{or}},TMPLIT_EXPR(src/regexp.ts:5) requires${, andERB_EXPR(src/regexp.ts:4) requires<%or%>, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise<template>.contentonly; no test covers shadow root content underSAFE_FOR_TEMPLATES. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.Affected Versions
npmdompurify>= 3.4.4, <= 3.4.163.4.16(npm registry)The lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the
_scrubTemplateExpressionssafety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add<template>.contentrecursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.Suggested Fix
Enforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to
<template>.content. After_sanitizeShadowDOMfinishes walking a shadow root, run_scrubTemplateExpressionsover that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have_scrubTemplateExpressionsdiscover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside<template>.content, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.Reported by zx (GitHub: @manus-pi).
Severity
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml
GHSA-c6gr-qvv4-5v3f
More information
Details
Summary
DOMPurify drops
attributeNameattributes whose value containshref, so an allowed<animate>or<set>can't rewrite a link's href after sanitizing. The check compares the attribute name aftertransformCaseFunc, and that function doesn't lowercase in XHTML mode. In XHTML the attribute is spelledattributeName, so the comparison with"attributename"never matches and the guard does nothing.An app that uses
PARSER_MEDIA_TYPE: 'application/xhtml+xml'and allowsanimateandattributeNamewill keep<animate attributeName="href" values="x;javascript:alert(1)">. Clicking the parent link then runs the script.This needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config.
Details
In
_sanitizeAttributes(dist/purify.js line 2216 in 3.4.16):transformCaseFuncisstringToLowerCasefor text/html but the identity function for application/xhtml+xml, because XML names are case-sensitive. The XML parser keeps SVG's real nameattributeName, so in XHTML modelcNameisattributeNameand the branch never runs.People using XHTML mode have to list SVG attributes in camelCase. The built-in attribute lists are lowercase and won't match, so
ALLOWED_ATTR/ADD_ATTR: ['attributeName', ...]is the config you'd naturally write here.The value also passes the URI check.
IS_ALLOWED_URIonly checks the start of the string, andx;javascript:alert(1)looks like a relative value. SMIL readsvaluesas a;list and, withfill="freeze", the last entry ends up as the effective href of the parent<a>.In text/html mode the same allowlist is handled correctly. The name is lowercased and
attributeNameis removed.This is close to the XHTML case-sensitivity hardening in 3.4.14/3.4.15 (Attack Classes & Bypass History §22): a check written for lowercase names that a case-preserved name gets past.
Proof of concept
Tested with DOMPurify 3.4.16 in Chrome 153 (headless shell). Also reproduced on 3.3.3, 3.3.1 and 3.2.7. Not confirmed for 2.x.
Config:
{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] }behaves the same.Input:
Steps:
poc.xhtmlnext topurify.js(3.4.16) and open it in Chrome. The.xhtmlextension makes Chrome load it as application/xhtml+xml, so nothing moves between HTML and XHTML.attributeName="href"andvalues="x;javascript:alert(1)".document.getElementById('q').href.animValreturns"javascript:alert(1)".alert(1)runs.As a control, remove
PARSER_MEDIA_TYPEand change the allowlist entry toattributename. The output then losesattributeName,href.animValis"", and clicking does nothing. The same XHTML-mode output also runs when inserted into a normal text/html page.An automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below.
poc.sh
Impact
Stored or reflected XSS on a click. It affects apps that sanitize SVG in XHTML mode and allow
animate(orset) withattributeName, for example to keep simple SVG animations. Script runs in the app's origin when a user clicks the sanitized link. Default configs and text/html mode aren't affected.Severity
Low
References
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
GHSA-cg2g-xh64-v4q7
More information
Details
Summary
When DOMPurify is configured with
SAFE_FOR_TEMPLATES: truetogether with a DOM-returning mode (RETURN_DOM: true,RETURN_DOM_FRAGMENT: true, orIN_PLACE: true), template expressions such as{{...}},${...}, and<%...%>survive sanitization inside the text content of an attached shadow root. The post-walk safety net_scrubTemplateExpressions(src/purify.ts:1717) was extended to recurse into<template>.content, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (theIN_PLACEand shadow-awareRETURN_DOMsurfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate.Root Cause
SAFE_FOR_TEMPLATESneutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (_stripTemplateExpressions, applied in_sanitizeElementsatsrc/purify.ts:2246), and a final post-walk pass (_scrubTemplateExpressions,src/purify.ts:1717) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content.Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.
Impact
An attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable
SAFE_FOR_TEMPLATESand a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically withattachShadow: in engines that support declarative shadow DOM, attacker-supplied markup containing<template shadowrootmode="open">parsed throughsetHTMLUnsafeorDocument.parseHTMLUnsafeproduces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context.Proof of Concept
Build a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with
SAFE_FOR_TEMPLATES:A downstream template evaluator reading
shadowRoot.textContent(for examplenew Function('return' + content + '')()or any${...}/{{...}}engine) then evaluates the fully formed expression.Attack Chain
DOMPurify.sanitize(node, cfg)(src/purify.ts:2854) withSAFE_FOR_TEMPLATES: trueandIN_PLACE: true(or a shadow-awareRETURN_DOM), passing a DOM node that already carries an attached shadow root.$,{alert(document.domain)}) is a valid, individually benign text node._sanitizeAttachedShadowRoots(src/purify.ts:2977) feeds the shadow root into_sanitizeShadowDOM(src/purify.ts:2710), which strips expressions per text node (src/purify.ts:2246) and removes the disallowed separator elements, leaving the fragments adjacent._scrubTemplateExpressions(src/purify.ts:3131-3132) is the only pass that would catch the merged expression, but it never recurses into shadow roots (src/purify.ts:1717-1751recurses into<template>.contentonly).shadowRoot.textContent/shadowRoot.innerHTML, reconstituting the complete expression.Bypass Evidence
The relevant protection is
SAFE_FOR_TEMPLATES, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to<template>.contentunder CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by addingquerySelectorAll('template')recursion atsrc/purify.ts:1743-1750. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of_scrubTemplateExpressions(src/purify.ts:1717,1747,3132,3141) shows none receive a shadow root, and_sanitizeShadowDOM(src/purify.ts:2710) never calls_scrubTemplateExpressionsornormalize(). The per-node strip cannot compensate:MUSTACHE_EXPR(src/regexp.ts:3) requires{{or}},TMPLIT_EXPR(src/regexp.ts:5) requires${, andERB_EXPR(src/regexp.ts:4) requires<%or%>, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise<template>.contentonly; no test covers shadow root content underSAFE_FOR_TEMPLATES. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.Affected Versions
npmdompurify>= 3.4.4, <= 3.4.163.4.16(npm registry)The lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the
_scrubTemplateExpressionssafety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add<template>.contentrecursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.Suggested Fix
Enforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to
<template>.content. After_sanitizeShadowDOMfinishes walking a shadow root, run_scrubTemplateExpressionsover that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have_scrubTemplateExpressionsdiscover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside<template>.content, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.Reported by zx (GitHub: @manus-pi).
Severity
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)
GHSA-mrv2-3pqr-j5jr
More information
Details
Summary
The 3.4.16 fix for GHSA-p98j-92pf-mc4p only covers a hook that removes the node it was called for. If an
IN_PLACEhook removes a different node, like the current node's parent or a sibling that has not been visited yet, that subtree is never walked or neutralized. Event handlers in it stay on the elements, and an<img onerror>that was already loading fires aftersanitize()returns.The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In IN_PLACE mode their subtree is neutralized inline, so an
<img onload>that was already loading when you built the live tree cannot fire aftersanitize()returns." That does not hold when the detached node is not the hook's own node.Tested versions
jsdom for the matrix below, and Chromium (Playwright) for the event actually firing.
Details
src/purify.tsline 2134 (dist/purify.cjs.jsline 2009 in 3.4.16),_handleHookDetachedNode():It only checks whether
currentNodelost its parent. When a hook removescurrentNode.parentNode(or any ancestor),currentNodestill has a parent, so nothing happens. The rest of the removed subtree is no longer reachable by the walker, and it is not inDOMPurify.removed, so the post walk pass does not see it either. The same happens when a hook removes a later sibling. This applies to all five element and attribute hooks.PoC (jsdom)
Output on 3.4.16:
In
selfmode the walker still reaches the second image and stripsonerror. In the other two modes the second image is detached with its handler still set. The same result is achieved with each ofbeforeSanitizeElements,uponSanitizeElement,afterSanitizeElements,beforeSanitizeAttributesandafterSanitizeAttributes.PoC (browser)
The same thing in a real page, served from 127.0.0.1 and opened in Chromium: