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 when url is falsy. If url is javascript:alert(1), it's truthy. The fallback never fires.
  • if (url.startsWith("http")) — doesn't reject javascript: or data: 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:

  1. fix(ui): validate source URL scheme before rendering as link
  2. fix(ui): validate facility.website scheme + extract shared URL guard
  3. fix(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, but data:image/svg+xml is a browser-dependent edge case worth flagging.