Skip to content

Cross-site scripting

Sign up, and the page says hello. The registration form takes a name, sends it to the server, and the server sends it back so the page can greet you with it.

That round trip is the whole bug.

Text that came from a visitor becomes dangerous the moment a page treats it as markup.

The bug

Here's the function that shows the greeting:

Vulnerable
ts
type RegisterResponse = { user: { name: string } }

function displaySuccess(response: RegisterResponse) {
  successBox.innerHTML = `Welcome, ${response.user.name}`
}

innerHTML means "parse this string as HTML". The browser reads it, builds whatever elements it describes, and wires up whatever behaviour those elements ask for.

For the name Priya that's harmless. The browser builds a text node and moves on.

For a name containing a tag, the browser builds that tag instead. It has no way to tell that this particular markup arrived from a stranger's keyboard, because by the time it reaches the parser it's part of your page.

That's cross-site scripting, usually written XSS: script injected into a site the victim trusts, running in the victim's browser. It has been around since the late 1990s and still turns up constantly, because the ingredients are ordinary. Take input, put it on a page.

JunoThe bug The browser isn't being careless here. It's doing precisely what it was told: innerHTML is a request to treat a string as page structure, so it does.

The trouble is that the string came from someone you've never met, and nothing in between said "this part is only text".

JunoThe bug The property being assigned to is the whole finding. When you review front end code, search for innerHTML, outerHTML, insertAdjacentHTML and document.write, then ask where each value came from.

These are called sinks: the places where a value stops being data and starts being interpreted. A value is only as safe as the sink it lands in, so the same name is fine in one line and dangerous in the next.

JunoThe bug Worth naming the three shapes, because they need different fixes. This one is reflected XSS: the value goes to the server and comes straight back in the response. Stored XSS is the same bug with patience, saved once and served to everyone who loads the page later.

DOM-based XSS never involves the server at all. The DOM is the browser's live object model of the page, and this variant is script that rewrites it using a value the page read for itself.

That payload can live in the URL fragment after the hash, which browsers do not send upstream.

That last one matters for how you look for these. Server logs cannot show you a DOM XSS payload, because the server never received it.

If log review is how you hunt this class of bug, there's a third of it you're structurally blind to.

The attack

A name is a text field, so nobody thinks of it as a place to put code. Type this into it instead:

html
<img src="x" onerror="alert('XSS successful')">

Submit the form. The server stores the value and echoes it back. displaySuccess hands it to innerHTML, the browser builds a real <img> element, tries to load an image called x, fails, and runs the onerror handler.

No <script> tag anywhere. That's the part people find surprising: blocking the word script stops almost nothing, because HTML has dozens of attributes that run code when something ordinary happens.

Run these against your own build only

The payloads here exist so you can recognise this bug in code you're responsible for. A stored one doesn't stay with you: it fires in the browser of whoever loads the page next.

That rules out anywhere with real visitors. Your own project, or a system you have written permission to test.

The script runs with the page's origin, so it can do whatever your own JavaScript could do:

What the script can reachWhat that means for the visitor
Cookies and browser storageTheir session can be copied and used elsewhere
The page itselfIt can be rewritten to say or ask for anything
Your API, as themRequests go out already authenticated
NavigationThey can be sent to a convincing copy of your site
JunoThe attack The word "script" makes this sound like it needs a <script> tag. It doesn't.

An image that fails to load, a page element the mouse passes over, an SVG that finishes loading: each of those can carry an instruction. Blocking one keyword leaves the rest.

JunoThe attack "Same origin" is the part to sit with. The script runs as your app, so every protection built on trusting your own front end is gone at that moment.

A practical consequence: HttpOnly on a session cookie stops the script reading it. It does nothing to stop the script sending requests that the browser attaches it to anyway. Good control, narrower than it looks.

JunoThe attack The reason blocklists lose is that the attack surface is the HTML specification, not a word list. Event-handler attributes alone number in the dozens, and they keep arriving.

This is why Content Security Policy, a response header telling the browser which sources of script it may run, is worth having even after your escaping is correct. It's a second layer for the day an output path gets missed.

The version that helps is nonce-based or hash-based. A policy containing unsafe-inline permits exactly the inline handler in this attack, which is the common way a policy ends up decorative.

Do not reach for it instead of fixing the sink. Reach for it because you will eventually miss a sink.

The fix

One property:

Fixed
ts
function displaySuccess(response: RegisterResponse) {
  successBox.textContent = `Welcome, ${response.user.name}`
}

textContent sets text. Not markup that might be text, text. The same payload now appears on the page as the literal characters <img src="x" onerror="alert('XSS successful')">, visible and inert.

JunoThe fix Notice what the fix doesn't do. It doesn't inspect the name, strip anything out of it, or decide whether it looks suspicious.

It changes what the browser was asked to do with it. The value is unchanged, and it's safe because it was never going to be run.

JunoThe fix Reach for textContent by default and treat every innerHTML as something that needs a reason. Most of them are there because someone wanted a line break or a bold word, which a small element built with createElement handles without opening the door.

When markup really does have to come from user input, rich text in a comment for instance, that's a sanitizer's job. A maintained one such as DOMPurify, never a regular expression you wrote, because the thing you are parsing is HTML and HTML is far stranger than it looks.

JunoThe fixtextContent is the correct escape for one context: HTML text. Contexts do not share an answer. The same value dropped into an attribute needs attribute encoding, into a URL needs URL encoding, into a <script> block needs JavaScript string encoding, and into CSS needs its own again.

The classic failure is a value escaped once, on the way in, and then reused somewhere with different rules. That's why escaping belongs at output, where the destination is known, and validation belongs at input, where you're deciding whether to accept the value at all.

Modern frameworks escape text interpolation for you, which removes most of this. They also each keep an escape hatch, dangerouslySetInnerHTML in React and v-html in Vue, and those names are the whole warning. Grep for them first in any review.

Why the fix works

The browser needs one of two instructions for any value: treat this as structure, or treat this as text. innerHTML gives the first, textContent gives the second. Same string, different instruction, different outcome.

The fix here is on the front end, which fixes this page and only this page. The value is still stored raw on the server, and storage is where it waits:

  • An admin dashboard listing recent signups.
  • An email template greeting the user by name.
  • A report exported for someone else to open.
  • Another team's client calling the same API.

Each of those is a different destination with different rules, and none of them knows what this one page decided. So the server can't trust the value either, which is where schema validation comes in later: refusing an absurd value on the way in shrinks what any destination has to survive.

JunoWhy the fix works One page being fixed doesn't make the value safe. It makes the value safe here.

The same name is still sitting in storage, waiting for the next screen that displays it, and that screen has to make its own decision.

JunoWhy the fix works When you find one of these, resist fixing only the line in the bug report. Search for every place that field is rendered, because a stored payload fires wherever it lands and admin tooling is usually the least reviewed surface in the codebase.

Admin pages are also the worst place for it to fire, since the session it borrows has the most authority.

JunoWhy the fix works This is why "sanitize on input" keeps failing as a strategy. It bakes one destination's assumptions into stored data, corrupts values that were legitimate, and gives you a table full of half-encoded strings nobody can safely reverse once a second consumer appears.

The durable division is: validate at the boundary because you are deciding whether to accept the value, escape at output because only the renderer knows where it is going. The OWASP prevention cheat sheet is the reference worth keeping open, and it is organised by output context for exactly this reason.

Try it

Three values, each typed into the name field of a page still using innerHTML. Work out which ones run, and what makes each one fire:

html
<div onmouseover="alert('one')">hover me</div>
<svg onload="alert('two')"></svg>
<iframe src="javascript:alert('three')"></iframe>
Compare your answers

All three run, and no two need the same thing from the visitor.

  • The div waits. It renders as ordinary text saying "hover me", and onmouseover fires when the pointer crosses it. Nothing happens until someone moves a mouse, which is why a payload can look harmless in a screenshot.
  • The svg doesn't wait. onload fires as soon as the element finishes parsing, so it runs the moment the greeting renders.
  • The iframe depends on the browser. The javascript: URL scheme is blocked in current browsers for framed navigation, so this is the one most likely to do nothing, and it's the reason a payload that fails proves very little. A different browser, an older one, or a slightly different sink can change the answer.

Swap the sink to textContent and all three become what they always were: strange text in a greeting.

Where this goes next

Every one of those payloads is a short string. They do damage by being interpreted, not by being large.

Denial of service turns that around, with input that never gets interpreted as anything and causes trouble purely by how much of it there is.