Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions dist/purify.cjs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/purify.cjs.js.map

Large diffs are not rendered by default.

29 changes: 25 additions & 4 deletions dist/purify.es.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1566,9 +1566,15 @@ function createDOMPurify() {
/**
* Handle a node whose tag is forbidden or not allowlisted: keep
* allowed custom elements (false return exits _sanitizeElements
* early - namespace/fallback checks and the afterSanitizeElements
* hook are intentionally skipped for kept custom elements), else
* hoist content per KEEP_CONTENT and remove.
* early - the namespace and fallback-tag removal checks are
* intentionally skipped for kept custom elements), else hoist
* content per KEEP_CONTENT and remove.
*
* A kept custom element is the ONLY case in which this function
* returns false, so the caller uses that return value to run the
* afterSanitizeElements hook on the kept element and keep the
* element-hook lifecycle consistent with normal allowlisted
* elements (GHSA-c2j3-45gr-mqc4).
*
* @param currentNode the disallowed node
* @param tagName the node's transformCaseFunc'd tag name
Expand Down Expand Up @@ -1680,7 +1686,22 @@ function createDOMPurify() {
}
/* Remove element if anything forbids its presence */
if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
return _sanitizeDisallowedNode(currentNode, tagName);
const removed = _sanitizeDisallowedNode(currentNode, tagName);
/* A false return means the node is a custom element kept via
CUSTOM_ELEMENT_HANDLING - the only keep path through
_sanitizeDisallowedNode. Run afterSanitizeElements on it so the
element-hook lifecycle matches normal allowlisted elements: a
security policy applied in this hook (e.g. stripping an attribute
from every surviving element) must not silently skip kept custom
elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
tail below - the hook runs, then the walker's subsequent
_sanitizeAttributes pass sanitizes the element's attributes. The
deliberately skipped namespace and fallback-tag removal checks stay
skipped; they are removal decisions, not the hook contract. */
if (removed === false) {
_executeHooks(hooks.afterSanitizeElements, currentNode, null);
}
return removed;
}
/* Check whether element has a valid namespace.
Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
Expand Down
2 changes: 1 addition & 1 deletion dist/purify.es.mjs.map

Large diffs are not rendered by default.

29 changes: 25 additions & 4 deletions dist/purify.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/purify.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/purify.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/purify.min.js.map

Large diffs are not rendered by default.

31 changes: 27 additions & 4 deletions src/purify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,9 +1763,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
/**
* Handle a node whose tag is forbidden or not allowlisted: keep
* allowed custom elements (false return exits _sanitizeElements
* early - namespace/fallback checks and the afterSanitizeElements
* hook are intentionally skipped for kept custom elements), else
* hoist content per KEEP_CONTENT and remove.
* early - the namespace and fallback-tag removal checks are
* intentionally skipped for kept custom elements), else hoist
* content per KEEP_CONTENT and remove.
*
* A kept custom element is the ONLY case in which this function
* returns false, so the caller uses that return value to run the
* afterSanitizeElements hook on the kept element and keep the
* element-hook lifecycle consistent with normal allowlisted
* elements (GHSA-c2j3-45gr-mqc4).
*
* @param currentNode the disallowed node
* @param tagName the node's transformCaseFunc'd tag name
Expand Down Expand Up @@ -1912,7 +1918,24 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
) &&
!ALLOWED_TAGS[tagName])
) {
return _sanitizeDisallowedNode(currentNode, tagName);
const removed = _sanitizeDisallowedNode(currentNode, tagName);

/* A false return means the node is a custom element kept via
CUSTOM_ELEMENT_HANDLING - the only keep path through
_sanitizeDisallowedNode. Run afterSanitizeElements on it so the
element-hook lifecycle matches normal allowlisted elements: a
security policy applied in this hook (e.g. stripping an attribute
from every surviving element) must not silently skip kept custom
elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
tail below - the hook runs, then the walker's subsequent
_sanitizeAttributes pass sanitizes the element's attributes. The
deliberately skipped namespace and fallback-tag removal checks stay
skipped; they are removal decisions, not the hook contract. */
if (removed === false) {
_executeHooks(hooks.afterSanitizeElements, currentNode, null);
}

return removed;
}

/* Check whether element has a valid namespace.
Expand Down
71 changes: 71 additions & 0 deletions test/test-suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -2155,6 +2155,77 @@
});
});

// Regression: GHSA-c2j3-45gr-mqc4. A custom element kept via
// CUSTOM_ELEMENT_HANDLING must run through afterSanitizeElements just
// like a normal allowlisted element, so a security policy layered in
// that hook is not silently skipped for kept custom elements.
QUnit.test(
'afterSanitizeElements fires on kept custom elements',
(assert) => {
const seen = [];
DOMPurify.addHook('afterSanitizeElements', (node) => {
if (node.tagName) {
seen.push(node.tagName.toLowerCase());
}
});
DOMPurify.sanitize('<x-keep>a</x-keep><div>b</div>', {
CUSTOM_ELEMENT_HANDLING: { tagNameCheck: /^x-/ },
});
DOMPurify.removeAllHooks();
assert.ok(
seen.includes('x-keep'),
`hook must fire on kept custom element, saw: ${seen.join(',')}`
);
assert.ok(seen.includes('div'), 'hook still fires on normal element');
}
);

QUnit.test(
'afterSanitizeElements policy applies uniformly to custom elements',
(assert) => {
DOMPurify.addHook('afterSanitizeElements', (node) => {
if (node.hasAttribute && node.hasAttribute('data-bio')) {
node.removeAttribute('data-bio');
}
});
const clean = DOMPurify.sanitize(
'<div data-bio="x"></div><x-bio data-bio="x"></x-bio>',
{ CUSTOM_ELEMENT_HANDLING: { tagNameCheck: /^x-/ } }
);
DOMPurify.removeAllHooks();
assert.equal(clean, '<div></div><x-bio></x-bio>');
}
);

QUnit.test(
'afterSanitizeElements can remove a kept custom element',
(assert) => {
DOMPurify.addHook('afterSanitizeElements', (node) => {
if (node.tagName && node.tagName.toLowerCase() === 'x-evil') {
node.remove();
}
});
const clean = DOMPurify.sanitize('<x-evil>a</x-evil><x-ok>b</x-ok>', {
CUSTOM_ELEMENT_HANDLING: { tagNameCheck: /^x-/ },
});
DOMPurify.removeAllHooks();
assert.equal(clean, '<x-ok>b</x-ok>');
}
);

QUnit.test(
'kept custom element attributes are still sanitized (contract intact)',
(assert) => {
const clean = DOMPurify.sanitize(
'<x-a onclick="alert(1)" href="javascript:alert(1)"><img src=x onerror=alert(1)></x-a>',
{ CUSTOM_ELEMENT_HANDLING: { tagNameCheck: /^x-/ } }
);
assert.notOk(/onclick/i.test(clean), 'event handler stripped');
assert.notOk(/javascript:/i.test(clean), 'javascript: URI stripped');
assert.notOk(/onerror/i.test(clean), 'child payload sanitized');
}
);

// =======================================================================
// Config: ALLOW_ARIA_ATTR (#198)
// =======================================================================
Expand Down