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:
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.
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".
The attack
A name is a text field, so nobody thinks of it as a place to put code. Type this into it instead:
<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 reach | What that means for the visitor |
|---|---|
| Cookies and browser storage | Their session can be copied and used elsewhere |
| The page itself | It can be rewritten to say or ask for anything |
| Your API, as them | Requests go out already authenticated |
| Navigation | They can be sent to a convincing copy of your site |
<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.
The fix
One property:
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.
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.
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.
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.
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:
<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
onmouseoverfires 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.
onloadfires 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.

