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:ormailto:. 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.openerto 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
Refererheader) to the new site. - Spread props
{...obj}in JSX, which turns every key ofobjinto 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.
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.
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.
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.
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.
// 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
Try a sneaky spelling
In the first example, add
"java\tscript:console.log('ran')"(a tab in the middle of the word) tosites. Predict whether React blocks it. (Yes: browsers ignore tabs and newlines in schemes, and so does React's check.) - 2
Be friendly to missing schemes
In the second example, change
safeUrlso that when parsing fails and the input has no:, it tries again with"https://" + input. Predict the new line forravi.example.com. Then check thatjavascript:void(0)is stillnull. - 3
Close the hole with a narrower spread
In the third example, change
UnsafeLinktofunction 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
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
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.')`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
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/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
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.
Break #2
Redirect wherever `next` says
After login, call navigate(searchParams.get("next") ?? "/") and render <a href={next}>Continue</a>.
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 onhref,src(frames),action,formActionandxlinkHref, in bothreact-dom/clientandreact-dom/server. - ▸
Passing a function to
actionorformActionis a React 19 form action (Topic 19.3), not a URL, so it isn't affected. On the server (Server Actions), React renders a specialactionvalue 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, andURL.parse(input)returnsnullinstead of throwing. - ▸
For user-generated images,
srcwithjavascript: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
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 ofjavascript:...is an XSS sink, even though React escaped the text perfectly: the value is a valid string, just a dangerous one. - 2
What React does: React 16.9 started warning about
javascript:URLs, and React 19 blocks them. When a URL prop such ashref(ona,area,link…),srcon frames,actionon forms orformActionon buttons starts withjavascript:(in any letter case, with leading spaces or control characters, even with tabs or newlines inside the word), React writesjavascript: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
What React doesn't do: it only knows about
javascript:. It doesn't blockdata:URLs (old browsers randata:text/htmlpages in a dangerous way; modern browsers block top-level navigation to them, but they still work in some places), it doesn't check where anhttps:link goes (a phishing site, an open redirect), and it doesn't addrelattributes. So for any URL that comes from a user or from data, validate it yourself: parse it withnew URL(input)and allow only the schemes you expect, usuallyhttps:(maybehttp:andmailto:). Parsing is important:new URLnormalises case and whitespace exactly like the browser does, so" JAVASCRIPT:..."can't sneak past a naivestartsWithcheck. - 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'snavigate(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
**
target="_blank"andrel.** A link withtarget="_blank"opens a new tab. In old browsers the new page got a reference back to your page throughwindow.openerand could silently change your tab's address to a fake login page (reverse tabnabbing).rel="noopener"removes that reference; browsers have applied it totarget="_blank"by default since about 2021, but writing it keeps older browsers and webviews safe.rel="noreferrer"also stops the browser sending theRefererheader, so a URL with private details (a reset token, a search) doesn't leak to the other site. Userel="noopener noreferrer"on external links, andwindow.open(url, "_blank", "noopener,noreferrer")in code. React adds neither for you. - 6
Spreading untrusted props (
<a {...data} />) is the attribute version of XSS. Every key in the object becomes a prop:dangerouslySetInnerHTMLreplaces the content,hreforformActionchange where things go,stylecan 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 surerestonly 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
What does React 19 do with a javascript: URL in href, and what doesn't it protect you from?
How do you validate a URL a user typed, and why not with a regex or startsWith?
What is an open redirect, and how do you prevent one in a React Router app?
Why is <Component {...apiData} /> dangerous?
Practice
Write ExternalLink that validates its href, opens in a new tab safely, and tells screen reader users it opens a new tab.
Write safeRedirect(next) and show its result for /orders?id=7, //evil.example, https://evil.example and undefined.
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 legitimatemailto:,tel:or app links; add schemes deliberately, one at a time. - ↔
A shared
ExternalLink/safeUrlhelper 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 rawhref={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 URLand 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.