I have reviewed the same line of code, written five different ways, and caught it once.
The line is element.innerHTML = something. I catch it when something is obviously a
URL parameter. I miss it when something arrived four frames ago through a message
event, got stored, and is written to the DOM by a function in a different file.
The sink was identical every time. My attention was not.
A sink is where data becomes execution: innerHTML, outerHTML,
insertAdjacentHTML, document.write, eval. You can list them on one hand, which is
why they feel solved. Grep for innerHTML. Ban it in review. Done.
A source is where untrusted data enters. Sources are not listable — they are a property of your architecture, and they multiply with every feature.
That asymmetry is the whole problem. The sink is a syntactic fact. The source is a trust fact, and trust facts do not survive being passed as a function argument.
The same sink, reached five ways. Every one is code I would call normal.
// 1. Direct — the one everybody catches
element.innerHTML = new URLSearchParams(location.search).get("q");
// 2. postMessage — another frame speaks, event.data is a string like any other
window.addEventListener("message", (e) => { element.innerHTML = e.data; });
// 3. FileReader — the user picked the file, which feels like consent
reader.addEventListener("load", () => { preview.innerHTML = reader.result; });
// 4. WebSocket — the connection is authenticated, so the payload feels authenticated
socket.addEventListener("message", (e) => { feed.insertAdjacentHTML("beforeend", e.data); });
// 5. Worker — it is our own worker, running our own code
worker.addEventListener("message", (e) => { results.innerHTML = e.data.html; });
Five doors, one room. On the first, a reviewer says "that's user input." On the other four, they say some version of "but that's ours."
Each of those four defences is a claim about provenance, and each is wrong the same way.
A message event fires for any frame with a handle on your window unless you check
event.origin — the listener does not filter by default. An authenticated WebSocket says
the connection is trusted, not what the other end sent, which may have been stored by a
different user hours earlier. A worker is your code, but the data it posts back is
whatever it was given. And a file the user chose is untrusted precisely because they
chose it.
The failure is not that developers think innerHTML is safe. Everyone knows. The failure
is that danger gets assessed at the sink, where the only visible fact is a string — and
the string looks the same whoever wrote it.
Ask a model to render a preview and it reaches for innerHTML, because that is the
shortest correct-looking answer to "put this markup on the page" and the training corpus
is fifteen years of tutorials using it.
More to the point: a model completing a message handler has the reviewer's problem,
worse. It sees a local scope. It cannot know whether that listener lives in an app that
checks event.origin elsewhere. The sink is local; the trust decision is not.
That is why this is interesting rather than merely annoying — the information needed to get it right is not present at the point of writing. A property of the problem, not of the author.
| Source | Trust claim that fails |
|---|---|
| URL / query params | none — everyone catches this |
postMessage | "another frame of ours" |
| WebSocket message | "the connection is authenticated" |
FileReader result | "the user chose the file" |
| Worker message | "it is our own worker" |
Read the right column. That is not five vulnerabilities. It is one — provenance assumed from proximity — wearing five costumes. A list of sinks tells you where to look; pairs tell you what to ask.
Grepping a sink is a heuristic. Following a source to a sink is taint tracking — the thing that decides your false-positive rate. Full taint tracking across frames, workers and sockets is not something a linter does; those boundaries are the line between linting and SAST. What a linter can do is match source and sink inside one handler — which covers all four cases above, because the risky pattern is nearly always written in a single function.
There are two classes here, and separating them is the point. The XSS outcome is CWE-79, under CWE-74 Injection. The trust failure is CWE-345 — or CWE-346 where there was an origin to check — under CWE-693. Different branches, not one above the other. Filing these as "XSS" is how the actual defect goes unrecorded. The taxonomy piece has the map.
Worth reading in full: OWASP's DOM-based XSS Prevention Cheat Sheet and PortSwigger on sources and sinks. To stop it being a review question at all, Trusted Types moves it to the browser — the only place that sees every assignment.
window.addEventListener("message", (event) => {
if (event.origin !== "https://trusted.example") return;
element.textContent = event.data;
});
Check the origin. Use textContent for text. Sanitize when you genuinely need markup.
None of that is the hard part.
The hard part is noticing you are in one of the five doorways — because four of them do not look like doorways. They look like your own code talking to itself.
Which door caught you? I am most interested in postMessage — the origin check is one
line, documented everywhere, and I still find handlers without it in code I wrote myself.
I write about measurement and static analysis at dev.to/ofri-peretz.
