Command Palette

Search for a command to run...

Hectal
PHASE 21Advanced ~28 min· topic 3 of 6

Topic 21.3

Unsafe URLs and Attributes

In one line

A URL can carry code (javascript:), point somewhere harmful, or hand your page to another tab. React 19 blocks javascript: URLs, but you still validate user URLs with new URL and a protocol allow-list, add rel="noopener noreferrer" to new-tab links, and never spread untrusted objects into props.

Think of it like this

A delivery note on a parcel that says "Leave at the back door". That's fine. A note that says "Open the front door and let the bearer in" is not an address at all: it's an instruction pretending to be one. A javascript: URL is that second note. It sits where an address should be (href), and when someone clicks it, the browser runs the code instead of going anywhere.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

URL scheme (protocol)
The part before the colon, such as https: or mailto:. It tells the browser what kind of address this is.
javascript: URL
A URL whose 'address' is JavaScript code. Clicking it runs the code in the current page.
data: URL
A URL that contains a whole file inline, such as data:image/png;base64,....
Open redirect
A page that forwards users to any URL given in a parameter, which attackers use to make phishing links look trusted.
Reverse tabnabbing
A page opened in a new tab using window.opener to change the original tab to a fake page.
rel="noopener"
Stops the new tab from getting window.opener, a reference to your page.
rel="noreferrer"
Also stops the browser sending your page's address (the Referer header) to the new site.
Spread props
{...obj} in JSX, which turns every key of obj into a prop.

Step by step

01See what React rewrites

React checks URL-type props when it sets them on the DOM. If the value matches its javascript: pattern, it substitutes a URL that throws. Everything else is written as is.

See what React rewritesdiagram
Rendering diagram…

02Validate user URLs with the URL parser

Parse, check the protocol against an allow-list, and render the normalised href. If it fails, show the text without a link. Do the same check on the server when the URL is saved, so other clients (mobile apps, emails) are safe too.

safeUrl.jswhole filejavascript
const ALLOWED = new Set(["https:", "http:", "mailto:"]);

export function safeUrl(input) {
  try {
    const url = new URL(String(input).trim());
    return ALLOWED.has(url.protocol) ? url.href : null;
  } catch {
    return null;
  }
}

03Only redirect to your own paths

For ?next= style parameters, accept a path, not a URL. Resolve it against your own origin and check the origin didn't change.

safeRedirect.jswhole filejavascript
export function safeRedirect(next, fallback = "/") {
  if (typeof next !== "string" || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) {
    return fallback;
  }
  const url = new URL(next, window.location.origin);
  return url.origin === window.location.origin ? url.pathname + url.search + url.hash : fallback;
}

// after login
navigate(safeRedirect(searchParams.get("next")), { replace: true });

04Make external links safe and honest

Wrap external links in one component so the rules live in one place: validated href, target="_blank", rel="noopener noreferrer", and text that tells users it opens a new tab.

ExternalLink.jsxwhole filejsx
export function ExternalLink({ href, children }) {
  const safe = safeUrl(href);
  if (!safe) return <span>{children}</span>;
  return (
    <a href={safe} target="_blank" rel="noopener noreferrer">
      {children} <span className="visually-hidden">(opens in a new tab)</span>
    </a>
  );
}

05Never spread data, only your own props

Forwarding {...rest} in a design-system component is fine because rest comes from the developer's JSX. Spreading an API response is not. Destructure what you need.

Banner.jsxwhole filejsx
// Bad: whatever the API returns becomes props
<Banner {...bannerFromApi} />

// Good: you decide the props
<Banner title={bannerFromApi.title} href={safeUrl(bannerFromApi.href)} />

Try it yourself

  1. 1

    Try a sneaky spelling

    In the first example, add "java\tscript:console.log('ran')" (a tab in the middle of the word) to sites. Predict whether React blocks it. (Yes: browsers ignore tabs and newlines in schemes, and so does React's check.)

  2. 2

    Be friendly to missing schemes

    In the second example, change safeUrl so that when parsing fails and the input has no :, it tries again with "https://" + input. Predict the new line for ravi.example.com. Then check that javascript:void(0) is still null.

  3. 3

    Close the hole with a narrower spread

    In the third example, change UnsafeLink to function UnsafeLink({ label, href }) { return <a className="unsafe" href={href}>{label}</a>; } without touching the call. Predict the first log line. (The overlay and the replaced content are gone: destructuring is the allow-list.)

Code & diagrams

React 19 blocks javascript: URLs (and only those) React 19+ New tab

Both `javascript:` spellings were replaced, and so were the form's `action` and the button's `formAction`. React printed no warning. The `data:` URL went through untouched: React's check is about `javascript:` only, so URL validation is still your job.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

a href: https://asha.dev
a href: javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')
a href: javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')
a href: data:text/html,<h1>hi</h1>
form action: javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')
button formaction: javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')
Validate user URLs with an allow-list New tab

`new URL` with no base rejects `ravi.example.com` (it has no scheme), so it's shown as plain text; you could try again with `https://` in front if you want to be friendly. The parser lower-cases the scheme and host, so `HTTPS://Shop...` is recognised and normalised. Links that pass open in a new tab with `rel="noopener noreferrer"`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

"https://asha.dev/blog" -> https://asha.dev/blog
"ravi.example.com" -> null
"data:text/html,hi" -> null
"javascript:void(0)" -> null
"mailto:hi@asha.dev" -> mailto:hi@asha.dev
"HTTPS://Shop.Example.com" -> https://shop.example.com/
Spreading untrusted data into props New tab

The spread let the data replace the link's content with its own HTML, and its `style` stretched the link over the **whole preview** (the faint red layer): every click anywhere now follows that link. `label` became a meaningless HTML attribute. The safe version renders only what you chose.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

unsafe: <a class="unsafe" label="Docs" href="https://react.dev" style="position: fixed; inset: 0px;"><b>replaced!</b></a>
safe: <a class="safe" href="https://react.dev">Docs</a>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Trust a startsWith check

Validate a URL with if (url.startsWith("javascript:")) return null; and then render <a href={url}> in a non-React context (a server template or innerHTML) where React's own check doesn't run.

terminal
$ Page
── what you'll see ──
A value like `" JavaScript:..."` or `"java\nscript:..."` passes your check, and the browser still treats it as a javascript: URL when clicked.

Break #2

Redirect wherever `next` says

After login, call navigate(searchParams.get("next") ?? "/") and render <a href={next}>Continue</a>.

terminal
$ Address bar
── what you'll see ──
A link like /login?next=https://evil.example sends a user who just logged in on your real site to another site that looks like yours.

Myth vs fact

Myth

React 19 makes every href safe.

Fact

It blocks the javascript: scheme only. data: URLs, phishing links, open redirects and rel are still yours to handle.

Myth

target="_blank" without rel is a big hole in modern browsers.

Fact

Current browsers treat target="_blank" as noopener by default. Still write rel="noopener noreferrer": older browsers and webviews exist, and noreferrer also protects private URLs.

Myth

Spreading props is fine because React refuses string event handlers.

Fact

Handlers are only one key. dangerouslySetInnerHTML, href, formAction, style and unknown attributes all come through a spread.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React's pattern is /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*:/i, mirroring how the URL parser strips leading control characters and ignores tabs and newlines. It runs on href, src (frames), action, formAction and xlinkHref, in both react-dom/client and react-dom/server.

  • ▸

    Passing a function to action or formAction is a React 19 form action (Topic 19.3), not a URL, so it isn't affected. On the server (Server Actions), React renders a special action value and handles the submit itself.

  • ▸

    new URL(input) can be expensive in a hot loop and throws on bad input; URL.canParse(input) (supported in current browsers) checks without throwing, and URL.parse(input) returns null instead of throwing.

  • ▸

    For user-generated images, src with javascript: can't run code in modern browsers, but an <img src> pointing at your own API (/api/logout) makes a GET request with the user's cookies. That's one reason state-changing endpoints must never accept GET (Topic 21.4).

Remember this

  1. 1

    A URL scheme (or protocol) is the part before the colon: https:, mailto:, tel:, data:, javascript:. Most schemes tell the browser where to go. The **javascript: scheme tells it to run** the rest as JavaScript in the current page when the link is clicked or the form is submitted. So <a href={user.website}> with a website of javascript:... is an XSS sink, even though React escaped the text perfectly: the value is a valid string, just a dangerous one.

  2. 2

    What React does: React 16.9 started warning about javascript: URLs, and React 19 blocks them. When a URL prop such as href (on a, area, link…), src on frames, action on forms or formAction on buttons starts with javascript: (in any letter case, with leading spaces or control characters, even with tabs or newlines inside the word), React writes javascript:throw new Error('React has blocked a javascript: URL as a security precaution.') instead. Clicking it throws that error instead of running the attacker's code. React logs nothing when it does this, so you won't notice unless you look.

  3. 3

    What React doesn't do: it only knows about javascript:. It doesn't block data: URLs (old browsers ran data:text/html pages in a dangerous way; modern browsers block top-level navigation to them, but they still work in some places), it doesn't check where an https: link goes (a phishing site, an open redirect), and it doesn't add rel attributes. So for any URL that comes from a user or from data, validate it yourself: parse it with new URL(input) and allow only the schemes you expect, usually https: (maybe http: and mailto:). Parsing is important: new URL normalises case and whitespace exactly like the browser does, so " JAVASCRIPT:..." can't sneak past a naive startsWith check.

  4. 4

    An open redirect is a page that sends the user wherever a URL parameter says: /login?next=https://evil.example. After login, your trusted domain forwards the user to a fake site. When you redirect after login (with React Router's navigate(next) or a <Navigate to={next}>), only accept paths on your own site: the value must start with a single /, not // (which means "another host") and not /\ (which browsers treat like //).

  5. 5

    **target="_blank" and rel.** A link with target="_blank" opens a new tab. In old browsers the new page got a reference back to your page through window.opener and could silently change your tab's address to a fake login page (reverse tabnabbing). rel="noopener" removes that reference; browsers have applied it to target="_blank" by default since about 2021, but writing it keeps older browsers and webviews safe. rel="noreferrer" also stops the browser sending the Referer header, so a URL with private details (a reset token, a search) doesn't leak to the other site. Use rel="noopener noreferrer" on external links, and window.open(url, "_blank", "noopener,noreferrer") in code. React adds neither for you.

  6. 6

    Spreading untrusted props (<a {...data} />) is the attribute version of XSS. Every key in the object becomes a prop: dangerouslySetInnerHTML replaces the content, href or formAction change where things go, style can draw a full-screen invisible link over your page, and unknown keys become HTML attributes. Event props are safe only because React refuses strings for them. Pick the fields you expect (<a href={check(data.href)}>{data.label}</a>), and when you forward props in a reusable component ({...rest}), make sure rest only ever comes from your own code, never from fetched data. In TypeScript, type the props narrowly so the compiler stops a whole API object being spread.

Explain it without notes

01

What does React 19 do with a javascript: URL in href, and what doesn't it protect you from?

02

How do you validate a URL a user typed, and why not with a regex or startsWith?

03

What is an open redirect, and how do you prevent one in a React Router app?

04

Why is <Component {...apiData} /> dangerous?

Practice

01

Write ExternalLink that validates its href, opens in a new tab safely, and tells screen reader users it opens a new tab.

02

Write safeRedirect(next) and show its result for /orders?id=7, //evil.example, https://evil.example and undefined.

03

A Card does <div {...card}>. The API returns { title, body, id }. Rewrite it safely.

Trade-offs

  • ↔

    A strict scheme allow-list (https: only) is safest, but rejects legitimate mailto:, tel: or app links; add schemes deliberately, one at a time.

  • ↔

    A shared ExternalLink/safeUrl helper puts the rules in one place, but only if the team actually uses it: back it with a lint rule or code review checklist for raw href={data...}.

Done when you can

  • I can explain what React 19 does with javascript: URLs and what it leaves to me.

  • I validate user URLs with new URL and a protocol allow-list.

  • I only redirect to same-site paths from ?next= style parameters.

  • I add rel="noopener noreferrer" to external links that open a new tab.

  • I never spread untrusted objects into JSX props.