TLDR: React does NOT block
javascript:URLs in<a href>. If you're pulling URLs from a database, you need a scheme check before render. Every time.
The Setup
I was doing a night audit on a cancer treatment finder — a Next.js app that helps cancer patients find specialized treatment facilities.
The data model is straightforward: facilities come in from TakeShape (our headless CMS), get synced to the DB via Drizzle (our ORM), and get surfaced in a React chat UI.
Admin-curated data. Trusted input. Or so I thought.
Three Files, One Hour
Three separate places where URLs from the database were flowing straight into rendered attributes — no validation, no check, nothing.
src/components/SourceCards.tsx—<a href={resolveSourceUrl(source.url)}>src/components/FacilityCard.tsx—<a href={facility.website}>src/lib/hubspot.ts—<iframe src={buildHubspotUrl(...)}>in two call sites
Each one: take the URL from a Drizzle row, interpolate it directly.
The trust model was "admin curates the field." And yeah, in normal operation that's probably fine.
But here's the failure shape that actually worried me: an admin account gets compromised, or an importer pulling data from TakeShape gets fed an unexpected value. Either way, you've just planted a clickable XSS vector on every chat session that surfaces that URL.
What Doesn't Work (And Why)
The reflex fix is usually one of these:
<a href={url || "#"}>— the#fallback only runs whenurlis falsy. Ifurlisjavascript:alert(1), it's truthy. The fallback never fires.if (url.startsWith("http"))— doesn't rejectjavascript:ordata:schemes.- Client-side regex sanitization — wrong primitive entirely.
And here's the one that surprised me most: React 18 and 19 warn on javascript: in href but do NOT block it. Click → script execution. I assumed the framework had my back. It doesn't.
The <iframe src> case is actually worse. src=data:text/html,<script>alert(1)</script> can run as same-origin in some browsers. That's not a warning. That's a hole.
The Fix
A single helper. Extracted on the third hit to src/lib/url-safety.ts:
export function safeExternalUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
try {
const parsed = new URL(raw);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
return parsed.toString();
}
return null;
} catch {
return null;
}
}
Returns the URL or null. Callers handle null by skipping the render entirely — not falling back to "#", which is a visible lie.
I wrote it inline the first time. Extracted it on the second hit. By the third file, I was just importing it. That is the right moment to extract a helper: when the pattern repeats, not in anticipation.
The three commits:
fix(ui): validate source URL scheme before rendering as linkfix(ui): validate facility.website scheme + extract shared URL guardfix(hubspot): scheme-validate iframe src before rendering
Why This Matters
I built this app to connect patients with real treatment options. The URL fields are populated by someone I trust. So the active attack surface is narrow.
But "narrow" isn't "zero."
The data flows through TakeShape, through a sync job, through Drizzle, into React. Any one of those hops could introduce an unexpected value. A javascript: URL sitting in a facility.website field would be invisible in the admin UI — it'd just look like a link — and would execute on every patient chat session that surfaced that facility.
In a single hour, we closed three holes. Worth it.
P.S. The audit pattern: grep for every
<a href=and<iframe src=that touches a non-literal value. Trace the source. If it came from DB or user input — scheme-check it. Also hit<video src>,<audio src>,<source src>.<img src>is lower risk, butdata:image/svg+xmlis a browser-dependent edge case worth flagging.