iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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();

Discussion

Loading…