GHSA-c2j3-45gr-mqc4 DOMPurify: `CUSTOM_ELEMENT_HANDLING` bypasses `afterSanitizeElements` for allowed custom elements.
SummaryThere is a possible hook-policy inconsistency in DOMPurify 3.4.11 involving `CUSTOM_ELEMENT_HANDLING`.When a custom element is allowed via `CUSTOM_ELEMENT_HANDLING.tagNameCheck`, it appears that the element does not go through `afterSanitizeElements` in the same way as a normal element. As a result, an application that relies on `afterSanitizeElements` as a security policy layer to strip sensitive attributes from all elements may see those attributes removed from normal elements but preserved on allowed custom elements.This does not appear to be a direct DOMPurify XSS or a case where DOMPurify directly allows executable payloads. The preserved value is still inert at sanitize time. The issue becomes relevant when the allowed custom element later re-injects that attribute value into an HTML sink such as `innerHTML`, creating a second-order XSS gadget.DetailsThe issue appears to originate from the control flow in `src/purify.ts`: line 1672~1691`CUSTOM_ELEMENT_HANDLING` is parsed from user configuration at `src/purify.ts`: line 741~748In particular, `tagNameCheck`, `attributeNameCheck`, and `allowCustomizedBuiltInElements` are copied into the internal `CUSTOM_ELEMENT_HANDLING` object there.During element sanitization, `_sanitizeElements()` checks whether a node is forbidden or not allowlisted at `src/purify.ts`: line 1805~1814If so, it immediately delegates to `_sanitizeDisallowedNode(currentNode, tagName)` and returns its boolean result.Inside `_sanitizeDisallowedNode()`, the custom-element-specific allow path is implemented at `src/purify.ts`: line 1672~1692If the node is treated as a basic custom element and `CUSTOM_ELEMENT_HANDLING.tagNameCheck` matches, the function returns `false` immediately at line 1682 or 1689, meaning “do not remove this node”.That early `return false` is significant because control returns directly to `_sanitizeElements()` via the `return _sanitizeDisallowedNode(...)` at line 1813. As a result, the later logic in `_sanitizeElements()` is skipped for that custom element instance, including:the namespace validation at `src/purify.ts`: line 1816~1826the fallback-tag mXSS check at `src/purify.ts`: line 1828~1837most importantly for this report, the `afterSanitizeElements` hook dispatch at `src/purify.ts`: line 1850~1851.In other words, a normal allowlisted element continues through `_sanitizeElements()` and reaches `hooks.afterSanitizeElements`, but a disallowed-by-default element that is revived by the `CUSTOM_ELEMENT_HANDLING.tagNameCheck` path does not. This creates a policy inconsistency: an application that relies on `afterSanitizeElements` to remove an attribute from all elements will observe that the policy is applied to normal elements but not to custom elements allowed through `CUSTOM_ELEMENT_HANDLING`.In the PoC, the application hook removes `data-bio` from ordinary elements, but the same attribute remains on `<x-bio>` because the custom-element keep path bypasses `afterSanitizeElements`. The attribute itself is inert at sanitize time and DOMPurify is not directly allowing executable SVG/HTML through. The security impact appears when the application-defined custom element later reads the preserved `data-bio` value in `connectedCallback()` and writes it to `innerHTML`, turning the preserved attribute into a second-order XSS gadget.PoCReproduced on DOMPurify 3.4.11.StepsSave the following HTML to a file, for example `poc.html`.Open it in a browser.Observe that the `div` control loses `data-bio`, while the allowed custom element keeps it.Observe that after `connectedCallback()` runs, the candidate payload is reinserted into the DOM and executes through the custom element’s own sink.HTML PoCExpected resultThis is output of HTML PoC.ImpactThis does not appear to affect DOMPurify’s default configuration as a direct sanitizer bypass.The impact is limited to applications that:enable `CUSTOM_ELEMENT_HANDLING`,rely on `afterSanitizeElements` as a security policy layer,expect that hook to apply uniformly to all surviving elements,and have allowed custom elements that later re-inject preserved attribute values into `innerHTML` or another HTML sink.In that situation, the behavior can become a second-order XSS gadget because a security-relevant attribute is removed from normal elements but remains on allowed custom elements.Possible fixes or mitigations might includeensuring that allowed custom elements also consistently pass through `afterSanitizeElements`documenting clearly that elements preserved via `CUSTOM_ELEMENT_HANDLING` may not participate in the same post-element hook flow as normal allowlisted elements.
CVSS v4.0 score: 2.1