Dangerous href / src
XSS via URL-bearing attributes (href, src, srcset, action, formaction) is one of the easiest variants to miss. The naive escape-on-output gets the inner text right but lets javascript: URLs through. The fix is URL scheme allowlisting plus strict CSP.
Allowlist schemes + CSP + sanitisers
EXAMPLE
// SCENARIO — a CMS that lets editors enter links. Defensive perspective.
// We focus on defenses, not weaponised payloads.
// ─── THE TRAP — encoding only the text ─────────────────────────
// HTML-encoding the link's TEXT does nothing about the href value.
// A href that begins with `javascript:` runs script in the visitor's browser.
// vbscript:, data:text/html, blob: with the wrong content-type — all dangerous.
function badRenderLink(url, label) {
return `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
// ❌ escapeHtml leaves 'javascript:' intact — only the text is safe
}
// ─── FIX 1 — Allowlist URL schemes ─────────────────────────────
const ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
function safeUrl(input) {
try {
const u = new URL(input, 'https://example.com'); // base for relative paths
if (!ALLOWED_SCHEMES.has(u.protocol)) return null; // reject any scheme not in the list
return u.toString();
} catch {
return null; // unparseable -> reject
}
}
function renderLink(url, label) {
const safe = safeUrl(url);
if (!safe) return `<span>${escapeHtml(label)}</span>`; // graceful fallback
return `<a href="${escapeHtml(safe)}" rel="noopener noreferrer">${escapeHtml(label)}</a>`;
}
// Notes:
// • Use the URL constructor — battle-tested parser, handles % encoding, IDN, relative paths
// • Always pass a base URL for relative inputs
// • Allowlist beats blocklist; new dangerous schemes appear over time
// ─── FIX 2 — Same allowlist for src, action, formaction ────────
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'background', 'poster']);
function setUrlAttr(el, attr, value) {
if (!URL_ATTRS.has(attr)) throw new Error('unsupported URL attribute');
const safe = safeUrl(value);
if (safe) el.setAttribute(attr, safe);
else el.removeAttribute(attr);
}
// ─── FIX 3 — srcset (responsive images) is its own beast ───────
// srcset uses comma-separated URLs + descriptors. Each URL must pass the same test.
function safeSrcset(input) {
return input
.split(',')
.map((part) => {
const trimmed = part.trim();
const [rawUrl, ...descriptors] = trimmed.split(/\s+/);
const safe = safeUrl(rawUrl);
if (!safe) return null;
return descriptors.length ? `${safe} ${descriptors.join(' ')}` : safe;
})
.filter(Boolean)
.join(', ') || null;
}
const srcset = safeSrcset('https://cdn.example.com/img1.jpg 1x, https://cdn.example.com/img2.jpg 2x');
if (srcset) imgEl.setAttribute('srcset', srcset);
// ─── FIX 4 — DOMPurify for any user-authored HTML ──────────────
import DOMPurify from 'dompurify';
const CONFIG = {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'img'],
ALLOWED_ATTR: ['href', 'title', 'rel', 'target', 'src', 'srcset', 'alt', 'sizes'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z+.\-]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
ADD_ATTR: ['target'],
};
// rel=noopener noreferrer for any link that opens in a new tab
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.nodeName === 'A' && node.hasAttribute('target')) {
node.setAttribute('rel', 'noopener noreferrer');
}
});
function renderRichHtml(target, dirty) {
target.innerHTML = DOMPurify.sanitize(dirty, CONFIG);
}
// ─── FIX 5 — Don't accept javascript:, vbscript:, or data:text/html ─
// Even when DOMPurify is enabled, explicitly reject these schemes in form validators server-side:
import { z } from 'zod';
const LinkSchema = z.object({
url: z.string().url().refine(
(v) => ['http:', 'https:'].includes(new URL(v).protocol),
{ message: 'Only http(s) URLs are allowed.' },
),
});
// ─── FIX 6 — Content Security Policy as defence in depth ───────
res.setHeader('Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'nonce-' + res.locals.nonce", // inline scripts only with nonce
"img-src 'self' data: https://cdn.example.com", // tight image origins
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"object-src 'none'",
].join('; '));
// With this CSP, even a malicious javascript: URL CAN'T execute — browsers refuse it.
// CSP is the safety net; URL allowlisting is the primary control.
// ─── FIX 7 — Don't rely on rendering pipelines that strip 'javascript:' silently ─
// Some frameworks (React, Vue) protect against javascript: hrefs at render time.
// Don't trust the framework — sanitise BEFORE storing AND at render time.
// A future migration to plain templates removes the protection.
// ─── FIX 8 — Explicit on-failure UX ────────────────────────────
// Always surface to the user when a URL is rejected, so they understand WHY their link disappeared.
function validateOrError(url) {
const safe = safeUrl(url);
if (!safe) {
notifyUser('Links must start with http:// or https://');
return null;
}
return safe;
}
// ─── REGRESSION TESTS ──────────────────────────────────────────
import { test, expect } from 'vitest';
const BAD_URLS = [
'javascript:alert(1)',
'JAVASCRIPT:alert(1)',
' javascript:alert(1)',
'\tjavascript:alert(1)',
'vbscript:msgbox',
'data:text/html,<script>alert(1)</script>',
'java\nscript:alert(1)',
'jav%09ascript:alert(1)', // tab-encoded
];
for (const u of BAD_URLS) {
test(`rejects ${JSON.stringify(u)}`, () => {
expect(safeUrl(u)).toBeNull();
});
}
const OK_URLS = [
'https://example.com',
'http://example.com/path?q=1',
'mailto:mara@example.com',
'tel:+61400000000',
'/relative/path',
];
for (const u of OK_URLS) {
test(`allows ${u}`, () => {
expect(safeUrl(u)).not.toBeNull();
});
}
// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Parse + allowlist URL schemes for every user-supplied URL
// 2. Same allowlist for href, src, action, formaction, srcset entries
// 3. Sanitise rich HTML via DOMPurify, configure ALLOWED_URI_REGEXP
// 4. Strict CSP with no 'unsafe-inline' scripts as defence in depth
// 5. Strip / refuse target=_blank links without rel=noopener noreferrer
// 6. Tests cover javascript:, vbscript:, data:, encoded whitespace
// 7. Validate URLs again at the server boundary, not just the editor
Why it matters
Escaping HTML doesn’t make a URL safe — javascript:, vbscript:, and data:text/html all sail through if you only run text-escape. Parse every user URL with the URL constructor, allowlist http(s)/mailto/tel, sanitise rich HTML with DOMPurify, and lean on a strict CSP as the final net.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// VULNERABLE: javascript: URI in href
<a href={user.profileUrl}>profile</a>
// SAFE: enforce protocol allow-list
const safe = /^https?:\/\//.test(user.profileUrl) ? user.profileUrl : '#';
Try it Yourself »
Exercise
Protocol prefix that should NOT be allowed in href.
if (url.startsWith('
:')) reject();
Ten letters.
Discussion
Loading…