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.
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.
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.