DOM XSS through a textContent sanitizer that decodes entities
A storefront component took its props from base64 JSON in the URL fragment and rendered one of them as raw HTML. The sanitizer removed markup by reading textContent, which decodes entities, so encoded markup came out of it as live HTML.
Every page on this storefront mounts the same component, a panel that slides in from the side. It can be opened from a link: the URL fragment carries a base64-encoded JSON object, and its fields become the panel’s props. One of those props is rendered as raw HTML.
Someone had thought about that, because there is a sanitizer between the fragment and the template. The bug is in the way that sanitizer removes markup.
Source to sink
The fragment value goes through a short pipeline: base64 decode, JSON parse, then the sanitizer over every string prop.
export const parsePanelParam = flatMap((value) =>
pipe(
decodeB64(value), // atob()
catchFailure(() => succeed(value)),
flatMap(parseJsonToPanelProps), // JSON.parse -> props
map(cleanStringProps) ,
catchFailure(() => succeed(value)),
),
);And in the component’s template, the headline prop:
{@html headline} The sanitizer
const cleanStringProps = (data) => {
const t = document.createElement("template");
Object.keys(data).forEach((k) => {
if (typeof data[k] !== "string") return;
t.innerHTML = data[k];
if (t.content.childElementCount < 1) return;
data[k] = `${t.content.textContent }<!-- html removed -->`;
});
return data;
};Each string is parsed into a template. If it contains at least one element, the sanitizer decides it’s markup and replaces it with its text content and a comment saying the HTML was taken out.
The problem is that textContent gives you the decoded text. < comes back as <, " comes back as ". So the “clean” string that goes back into the props can contain markup that was never markup on the way in, and {@html} parses it again:
input <div><img src=x onerror=window.__poc=1></div>
output <img src=x onerror=window.__poc=1><!-- html removed -->
{@html} a real <img> element, and its onerror runsNote the outer <div>. Without an element in the value, childElementCount is 0 and the sanitizer returns early, leaving the string alone. The encoded payload would then render as harmless text. To get it decoded you have to hand the sanitizer something it thinks is dangerous, so that it does the one transformation that makes the payload live.
Because " decodes as well, the sanitizer also puts back the quotes a quoted event handler needs. That’s the difference between a one-statement proof and running whatever script you want.
Fragment delivery
Everything after # stays in the browser. The base64 is decoded client-side, so the payload never reaches the server, and the WAF and the bot protection in front of the site never see it. I put that in the report up front, because the first reply to a client-side XSS is often that the edge would block it.
The link itself is clean: the real domain, a valid certificate, and any page you like, since every page mounts the panel. That includes the account pages for a logged-in victim.
Proof of concept
I kept the proof benign so the link could be shared with the vendor without risk to whoever opened it. The decoded fragment:
{"headline": "<div><img src=x onerror=window.__poc=1 ></div>"}Base64 it, add it to the fragment of any URL on the site, open it, then check the console:
> window.__poc
1 Fix
Don’t hand sanitizer output back to a raw-HTML sink as a string. If the headline is text, render it as text. If it really needs markup, use a sanitizer that builds a safe DOM tree and insert that tree, with an allowlist of elements and attributes, instead of a string the next parser will read differently.
A sanitizer that transforms its input instead of rejecting it creates a new input for whatever comes next, so check what its output does when it reaches the sink. Decode, then parse is the usual shape, and it turns up with URLs, paths and SQL as often as with HTML. A comment in the output announcing that something was removed for security is a decent hint that someone wrote their own sanitizer.
Disclosure
Reported to the vendor and fixed. The storefront isn’t named. Function names, the fragment parameter and the comment string are changed.