When you render HTML that came from a user — a comment, a profile bio, a chat message — you can't trust it. A <script> tag or an onclick="..." attribute slipped into that markup runs in your page with your user's session. A sanitizer defends against this by allowing only a known-safe set of tags and attributes through and removing everything else. You'll implement a simplified one that cleans a DOM subtree in place.
type Options = {
allowedTags: string[]; // tag names to keep, e.g. ['p', 'a', 'b']
allowedAttributes: string[]; // attribute names to keep, e.g. ['href', 'id']
};
function htmlSanitizer(root: Element, options: Options): Element;
You receive a root element whose subtree may contain untrusted markup. Mutate that subtree in place and return the same root. Comparisons of tag and attribute names are case-insensitive.
const root = document.createElement('div');
root.innerHTML = '<p onclick="steal()">hi</p><script>alert(1)</script>';
htmlSanitizer(root, { allowedTags: ['p'], allowedAttributes: [] });
root.innerHTML;
// → '<p>hi</p>'
// the <script> is gone; the <p> stays but its onclick is stripped
const root = document.createElement('div');
root.innerHTML = '<a href="/home" onclick="evil()">go</a>';
htmlSanitizer(root, { allowedTags: ['a'], allowedAttributes: ['href'] });
root.innerHTML;
// → '<a href="/home">go</a>' (href kept, onclick dropped)
allowedTags, remove the element and its whole subtree. Don't unwrap it or keep its children.allowedAttributes. This is what neutralizes onclick, onerror, style, and friends.<script> or an onerror arbitrarily deep in the tree — you must clean the whole subtree, not just direct children.DIV and div, HREF and href, as the same name.root; don't build and return a new one.You'll walk the DOM subtree and apply two rules at every element: drop it if its tag isn't allowed, and otherwise strip any attribute that isn't allowed.
Your app takes some HTML a user typed and puts it on the page. That HTML might contain a <script> that runs code, or a <p onclick="..."> that fires code when someone clicks it. Either one runs in your page, with your user's logged-in session — that's a cross-site scripting (XSS) attack. A sanitizer fixes this by keeping only an allowlist of safe tags and safe attributes and deleting everything else. You're given the parsed DOM root, and you clean its subtree in place.
There are exactly two decisions to make at every element, and they're independent. First, the tag decision: is this element's tag on the allowlist? If not, delete the whole element (and everything inside it) and you're done with this branch. Second, the attribute decision: the tag is allowed, so the element stays — but now look at each of its attributes and remove the ones that aren't on the attribute allowlist. Then recurse into its children and repeat. Two checks, applied at every node, top to bottom.
The obvious version handles the tags and recurses — but stops there:
function htmlSanitizer(root, options) {
const allowedTags = new Set(options.allowedTags.map((t) => t.toLowerCase()));
for (const child of Array.from(root.children)) {
if (!allowedTags.has(child.tagName.toLowerCase())) {
child.remove(); // drop disallowed tags
} else {
htmlSanitizer(child, options); // recurse into allowed ones
}
}
return root;
}
This removes <script> and <iframe> correctly, so it looks safe. But it never touches attributes. A <p> is an allowed tag, so it stays — and its onclick="steal()" stays right along with it. The most common XSS payload isn't a <script> tag at all; it's an event handler attribute on an otherwise innocent element. This version leaves the door wide open.
function htmlSanitizer(root, options) {
const allowedTags = new Set(options.allowedTags.map((t) => t.toLowerCase()));
const allowedAttrs = new Set(
options.allowedAttributes.map((a) => a.toLowerCase()),
);
// Array.from snapshots the live children list. Without it, calling
// child.remove() mid-iteration would shift the collection under us and skip
// the next sibling.
for (const child of Array.from(root.children)) {
if (!allowedTags.has(child.tagName.toLowerCase())) {
child.remove(); // disallowed tag → drop the element and its whole subtree
} else {
// Allowed tag stays, but filter its attributes. Snapshot again because
// removeAttribute mutates the live attributes collection.
for (const attr of Array.from(child.attributes)) {
if (!allowedAttrs.has(attr.name.toLowerCase())) {
child.removeAttribute(attr.name);
}
}
htmlSanitizer(child, options); // recurse into the cleaned element
}
}
return root;
}
module.exports = { htmlSanitizer };
The key shift from the naive version is the inner loop over child.attributes: for every element we keep, we now also remove any attribute not on the allowlist. We lower-case both sides of every comparison so DIV/div and HREF/href match (the DOM stores tag names upper-cased and attribute names lower-cased, but the allowlists could be written either way). And we wrap both live collections in Array.from so that removing items mid-loop doesn't make the iterator skip elements.
Take htmlSanitizer(root, { allowedTags: ['a', 'p'], allowedAttributes: ['href'] }) on a root whose innerHTML is <a href="/x" onclick="evil()">go</a><script>bad()</script>:
root.children: [<a>, <script>]. We iterate this fixed array even as we mutate the tree.<a>. tagName.toLowerCase() is 'a', which is in allowedTags — keep it. Now snapshot its attributes: [href, onclick]. href is allowed, so it stays; onclick is not, so removeAttribute('onclick'). Then recurse into <a>; it has no element children, so nothing happens.<script>. 'script' is not in allowedTags, so child.remove() deletes it (and would delete any subtree it had).root.innerHTML is now <a href="/x">go</a> — the script is gone and the dangerous handler is stripped, while the safe link survives. We return the same root.onclick/onerror/style on allowed elements — the real XSS vector. Fix: loop over each kept element's attributes and remove the ones not on the allowlist.element.children and element.attributes are live — calling remove() or removeAttribute() shifts them and the loop skips the next item. Fix: snapshot with Array.from(...) before iterating.child.tagName is upper-cased ('P') and attribute names are lower-cased by the DOM, but the allowlists might be written in any case. Comparing without normalizing silently drops or keeps the wrong things. Fix: lower-case both sides.<script> or onerror survives. Fix: recurse into every kept element.href: a kept href="javascript:alert(1)" is still an attack. Production sanitizers also validate the value (allowed URL schemes, no data: for some tags).href on <a> but not on <div>) and add a tag-name namespace check to block SVG/MathML smuggling. Our flat allowlists are a deliberate simplification.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
When you render HTML that came from a user — a comment, a profile bio, a chat message — you can't trust it. A <script> tag or an onclick="..." attribute slipped into that markup runs in your page with your user's session. A sanitizer defends against this by allowing only a known-safe set of tags and attributes through and removing everything else. You'll implement a simplified one that cleans a DOM subtree in place.
type Options = {
allowedTags: string[]; // tag names to keep, e.g. ['p', 'a', 'b']
allowedAttributes: string[]; // attribute names to keep, e.g. ['href', 'id']
};
function htmlSanitizer(root: Element, options: Options): Element;
You receive a root element whose subtree may contain untrusted markup. Mutate that subtree in place and return the same root. Comparisons of tag and attribute names are case-insensitive.
const root = document.createElement('div');
root.innerHTML = '<p onclick="steal()">hi</p><script>alert(1)</script>';
htmlSanitizer(root, { allowedTags: ['p'], allowedAttributes: [] });
root.innerHTML;
// → '<p>hi</p>'
// the <script> is gone; the <p> stays but its onclick is stripped
const root = document.createElement('div');
root.innerHTML = '<a href="/home" onclick="evil()">go</a>';
htmlSanitizer(root, { allowedTags: ['a'], allowedAttributes: ['href'] });
root.innerHTML;
// → '<a href="/home">go</a>' (href kept, onclick dropped)
allowedTags, remove the element and its whole subtree. Don't unwrap it or keep its children.allowedAttributes. This is what neutralizes onclick, onerror, style, and friends.<script> or an onerror arbitrarily deep in the tree — you must clean the whole subtree, not just direct children.DIV and div, HREF and href, as the same name.root; don't build and return a new one.You'll walk the DOM subtree and apply two rules at every element: drop it if its tag isn't allowed, and otherwise strip any attribute that isn't allowed.
Your app takes some HTML a user typed and puts it on the page. That HTML might contain a <script> that runs code, or a <p onclick="..."> that fires code when someone clicks it. Either one runs in your page, with your user's logged-in session — that's a cross-site scripting (XSS) attack. A sanitizer fixes this by keeping only an allowlist of safe tags and safe attributes and deleting everything else. You're given the parsed DOM root, and you clean its subtree in place.
There are exactly two decisions to make at every element, and they're independent. First, the tag decision: is this element's tag on the allowlist? If not, delete the whole element (and everything inside it) and you're done with this branch. Second, the attribute decision: the tag is allowed, so the element stays — but now look at each of its attributes and remove the ones that aren't on the attribute allowlist. Then recurse into its children and repeat. Two checks, applied at every node, top to bottom.
The obvious version handles the tags and recurses — but stops there:
function htmlSanitizer(root, options) {
const allowedTags = new Set(options.allowedTags.map((t) => t.toLowerCase()));
for (const child of Array.from(root.children)) {
if (!allowedTags.has(child.tagName.toLowerCase())) {
child.remove(); // drop disallowed tags
} else {
htmlSanitizer(child, options); // recurse into allowed ones
}
}
return root;
}
This removes <script> and <iframe> correctly, so it looks safe. But it never touches attributes. A <p> is an allowed tag, so it stays — and its onclick="steal()" stays right along with it. The most common XSS payload isn't a <script> tag at all; it's an event handler attribute on an otherwise innocent element. This version leaves the door wide open.
function htmlSanitizer(root, options) {
const allowedTags = new Set(options.allowedTags.map((t) => t.toLowerCase()));
const allowedAttrs = new Set(
options.allowedAttributes.map((a) => a.toLowerCase()),
);
// Array.from snapshots the live children list. Without it, calling
// child.remove() mid-iteration would shift the collection under us and skip
// the next sibling.
for (const child of Array.from(root.children)) {
if (!allowedTags.has(child.tagName.toLowerCase())) {
child.remove(); // disallowed tag → drop the element and its whole subtree
} else {
// Allowed tag stays, but filter its attributes. Snapshot again because
// removeAttribute mutates the live attributes collection.
for (const attr of Array.from(child.attributes)) {
if (!allowedAttrs.has(attr.name.toLowerCase())) {
child.removeAttribute(attr.name);
}
}
htmlSanitizer(child, options); // recurse into the cleaned element
}
}
return root;
}
module.exports = { htmlSanitizer };
The key shift from the naive version is the inner loop over child.attributes: for every element we keep, we now also remove any attribute not on the allowlist. We lower-case both sides of every comparison so DIV/div and HREF/href match (the DOM stores tag names upper-cased and attribute names lower-cased, but the allowlists could be written either way). And we wrap both live collections in Array.from so that removing items mid-loop doesn't make the iterator skip elements.
Take htmlSanitizer(root, { allowedTags: ['a', 'p'], allowedAttributes: ['href'] }) on a root whose innerHTML is <a href="/x" onclick="evil()">go</a><script>bad()</script>:
root.children: [<a>, <script>]. We iterate this fixed array even as we mutate the tree.<a>. tagName.toLowerCase() is 'a', which is in allowedTags — keep it. Now snapshot its attributes: [href, onclick]. href is allowed, so it stays; onclick is not, so removeAttribute('onclick'). Then recurse into <a>; it has no element children, so nothing happens.<script>. 'script' is not in allowedTags, so child.remove() deletes it (and would delete any subtree it had).root.innerHTML is now <a href="/x">go</a> — the script is gone and the dangerous handler is stripped, while the safe link survives. We return the same root.onclick/onerror/style on allowed elements — the real XSS vector. Fix: loop over each kept element's attributes and remove the ones not on the allowlist.element.children and element.attributes are live — calling remove() or removeAttribute() shifts them and the loop skips the next item. Fix: snapshot with Array.from(...) before iterating.child.tagName is upper-cased ('P') and attribute names are lower-cased by the DOM, but the allowlists might be written in any case. Comparing without normalizing silently drops or keeps the wrong things. Fix: lower-case both sides.<script> or onerror survives. Fix: recurse into every kept element.href: a kept href="javascript:alert(1)" is still an attack. Production sanitizers also validate the value (allowed URL schemes, no data: for some tags).href on <a> but not on <div>) and add a tag-name namespace check to block SVG/MathML smuggling. Our flat allowlists are a deliberate simplification.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.