Skip to content

CWE-601: Open Redirect - JavaScript/Node.js

Overview

Open redirect vulnerabilities in Node.js web applications occur when user-controlled input is used in res.redirect(), res.writeHead() Location headers, or client-side window.location assignments without validation, enabling phishing attacks and credential theft. Express, Koa, and vanilla Node.js HTTP servers all require careful handling of redirect destinations.

Primary Defence: Where the destination does not have to come from the request at all, use the indirect pattern below - a server-side map from an opaque key to a URL removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.

Where it does: for local redirects, validate that user-supplied URLs are relative paths using new URL() with a base URL and checking that the resulting hostname matches your application's hostname. For external redirects, use an explicit allowlist of permitted domains with exact hostname matching. Reject protocol-relative URLs (//evil.com), JavaScript URLs (javascript:), and data URLs (data:). Always fail-closed with a safe default redirect when validation fails.

Common Vulnerable Patterns

Unvalidated Express Redirect

const express = require('express');
const app = express();

// VULNERABLE - No validation
app.get('/login', (req, res) => {
    // Authenticate user...

    const returnUrl = req.query.returnUrl;
    res.redirect(returnUrl);  // Dangerous!
});

// Attack: /login?returnUrl=https://evil.com/phishing

Why this is vulnerable:

  • req.query.returnUrl retrieves user-controlled input directly from URL parameters
  • res.redirect() accepts any URL without validation, including absolute URLs to attacker domains
  • Missing null/undefined check causes errors when parameter is omitted
  • No validation of protocol-relative URLs (//evil.com), JavaScript URLs, or data URLs

Unvalidated HTTP Server Redirect

const http = require('http');
const url = require('url');

// VULNERABLE - Direct redirect from query string
http.createServer((req, res) => {
    const queryParams = url.parse(req.url, true).query;
    const redirectUrl = queryParams.next;

    res.writeHead(302, { 'Location': redirectUrl });
    res.end();
}).listen(3000);

// Attack: /?next=https://evil.com/fake-login

Why this is vulnerable:

  • queryParams.next retrieves user input without validation
  • Setting Location header directly with user input allows arbitrary redirects
  • No validation that URL is local to the application
  • Missing null check causes Location: undefined header

Client-Side Redirect Without Validation

// VULNERABLE - Client-side redirect from URL parameter
const urlParams = new URLSearchParams(window.location.search);
const redirectUrl = urlParams.get('next');

if (redirectUrl) {
    window.location = redirectUrl;  // Dangerous!
}

// Attack: page.html?next=https://evil.com/phishing
// or: page.html?next=javascript:alert(document.cookie)

Why this is vulnerable:

  • URLSearchParams parses user-controlled query string
  • window.location assignment accepts any URL including JavaScript URLs
  • No validation of redirect destination
  • Client-side validation can be bypassed by direct HTTP requests

Secure Patterns

Express: Validate Local URLs

// SECURE - Express: resolve against a base and require the same origin
const express = require('express');
const app = express();

// Any placeholder origin works - the value is never sent, it only gives
// the parser a base to resolve against
const PARSE_BASE = 'http://localhost';

function isLocalUrl(url) {
    if (!url || typeof url !== 'string') {
        return false;
    }

    // Browsers delete tab, CR and LF before resolving a URL, and CR or LF
    // in a Location header is a header injection - reject control characters
    if (/[\u0000-\u001F\u007F]/.test(url)) {
        return false;
    }

    try {
        // Resolve the same way a browser resolves a Location header
        const parsed = new URL(url, PARSE_BASE);

        // Anything that lands on another origin is external
        return parsed.origin === PARSE_BASE && url.startsWith('/');

    } catch (e) {
        return false;  // Malformed URL
    }
}

app.get('/login', (req, res) => {
    // Authenticate user...

    const returnUrl = req.query.returnUrl;

    if (returnUrl && isLocalUrl(returnUrl)) {
        res.redirect(returnUrl);
    } else {
        res.redirect('/');  // Safe default
    }
});

Why this works:

  • new URL(url, PARSE_BASE) resolves the value with the same algorithm the browser applies to a Location header, so the check sees the destination the browser will see rather than the string the attacker sent
  • Comparing parsed.origin against the base origin rejects every external form in one test: https://evil.com, the protocol-relative //evil.com, and /\evil.com - which resolves to http://evil.com/ because the URL parser converts backslashes to slashes for HTTP URLs. A startsWith('//') test on the raw string misses that last one
  • javascript: and data: URLs parse with the opaque origin "null", so they fail the same comparison
  • The control-character test is there for CR and LF. The parser deletes tab, CR and LF before resolving, which is what makes /<tab>/evil.com fail the origin test on its own - but the same deletion turns /p\r\nSet-Cookie: a=b into the same-origin path /pSet-Cookie: a=b, so the origin comparison alone passes a header-injection payload and leaves the outcome to whichever sink follows. Measured on Express 5.2.1 and Node 24: res.redirect() percent-encodes the pair, while a raw res.writeHead(302, { Location: value }) throws ERR_INVALID_CHAR. Rejecting control characters in the validator keeps that decision out of the sink
  • startsWith('/') keeps the accepted set to paths, rejecting bare relative references like dashboard. It also closes a case the origin test alone does not: with a base whose scheme matches, a scheme with a missing slash such as http:/evil.com resolves relative to the base and passes the origin comparison - measured on Node 24, new URL('http:/evil.com', 'http://localhost') is http://localhost/evil.com - and a browser on an https page resolving the same raw Location reads it as http://evil.com/
  • Try-catch block safely handles malformed URLs by returning false instead of throwing exceptions
  • Type check typeof url !== 'string' prevents non-string inputs
  • Null/undefined check prevents errors when parameter is missing
  • Fail-closed behavior redirects to / when validation fails

Using URL Parser with Origin Validation

// SECURE - compare the full origin against the configured APP_ORIGIN
const express = require('express');
const { URL } = require('url');
const app = express();

function isLocalUrl(url, baseUrl) {
    if (!url || typeof url !== 'string') {
        return false;
    }

    // Browsers delete tab, CR and LF before resolving a URL, and CR or LF
    // in a Location header is a header injection - reject control characters
    if (/[\u0000-\u001F\u007F]/.test(url)) {
        return false;
    }

    try {
        const parsed = new URL(url, baseUrl);
        const base = new URL(baseUrl);

        // Check full origin, not just hostname
        return parsed.origin === base.origin;

    } catch (e) {
        return false;
    }
}

app.get('/redirect', (req, res) => {
    const targetUrl = req.query.url;
    const baseUrl = process.env.APP_ORIGIN;

    if (targetUrl && isLocalUrl(targetUrl, baseUrl)) {
        // Redirect to the URL that was validated, not the string that was
        // sent: the two differ for inputs the parser repairs
        res.redirect(new URL(targetUrl, baseUrl).href);
    } else {
        res.redirect('/');
    }
});

Why this works:

  • new URL(url, baseUrl) resolves relative URLs against application's base URL, converting /path to http://yourapp.com/path
  • parsed.origin === base.origin requires scheme, hostname, and port to match
  • Handles both relative URLs (/dashboard) and absolute URLs on the same configured origin
  • The route redirects to new URL(targetUrl, baseUrl).href - the value that was validated - rather than to the raw parameter. The two differ for inputs the parser repairs: with an https base, https:/evil.com resolves to https://yourapp.com/evil.com and passes the origin check, which is correct for a browser on that origin, but emitting the raw string puts a scheme-with-one-slash into the Location header for every client and proxy to resolve by its own rules. Emitting href sends the canonical absolute URL the check approved, and nothing downstream gets to disagree about it
  • Control characters are rejected before parsing, for the reason given under the Express example above
  • APP_ORIGIN should be configured server-side; do not derive the trusted origin from an unvalidated Host header
  • Prevents subdomain bypasses by requiring exact origin match
  • Try-catch handles malformed URLs safely

Allowlist External Domains

// SECURE - allowlist of external hosts, exact match after parsing
const express = require('express');
const { URL } = require('url');
const app = express();

const ALLOWED_DOMAINS = new Set([
    'example.com',
    'www.example.com',
    'partner.example.org'
]);

function isAllowedUrl(url, req) {
    if (!url || typeof url !== 'string') {
        return false;
    }

    // Browsers delete tab, CR and LF before resolving a URL, and CR or LF
    // in a Location header is a header injection - reject control characters
    if (/[\u0000-\u001F\u007F]/.test(url)) {
        return false;
    }

    try {
        const baseUrl = process.env.APP_ORIGIN;
        const parsed = new URL(url, baseUrl);
        const base = new URL(baseUrl);

        // Allow same origin
        if (parsed.origin === base.origin) {
            return true;
        }

        // Check external allowlist
        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
            return false;
        }

        // Exact hostname match (case-insensitive)
        if (parsed.username || parsed.password || parsed.port) {
            return false;
        }
        return ALLOWED_DOMAINS.has(parsed.hostname.toLowerCase());

    } catch (e) {
        return false;
    }
}

app.get('/external', (req, res) => {
    const targetUrl = req.query.url;

    if (targetUrl && isAllowedUrl(targetUrl, req)) {
        // Redirect to the URL that was validated, not the string that was
        // sent: the two differ for inputs the parser repairs
        res.redirect(new URL(targetUrl, process.env.APP_ORIGIN).href);
    } else {
        res.redirect('/');
    }
});

Why this works:

  • Set provides O(1) lookup for allowed domains with automatic deduplication
  • Combines same-origin validation with an external domain allowlist; the same-origin base should come from trusted configuration
  • The route redirects to the parsed href, not the raw parameter, and rejects control characters before parsing - both for the reasons given under the two examples above
  • parsed.protocol check blocks JavaScript URLs (javascript:), data URLs (data:), file URLs (file:)
  • parsed.hostname.toLowerCase() performs case-insensitive matching, preventing ExAmPlE.cOm bypasses
  • Separate validation for same-origin vs external URLs keeps local and partner redirect rules distinct
  • Handles both relative and absolute URLs correctly with URL parser
  • Fail-closed default redirects to / for invalid/unlisted domains

Indirect Redirects (Best Practice)

// SECURE - indirect redirect: the request carries a key, never a URL
const express = require('express');
const app = express();

// A Map, not a plain object: a plain object's lookup also finds inherited
// names, so REDIRECT_MAP['constructor'] would be Object.prototype.constructor
const REDIRECT_MAP = new Map([
    ['dashboard', '/dashboard'],
    ['profile', '/user/profile'],
    ['settings', '/user/settings']
]);

app.get('/goto', (req, res) => {
    const dest = req.query.dest;

    const redirectUrl = REDIRECT_MAP.get(dest);

    if (redirectUrl) {
        res.redirect(redirectUrl);
    } else {
        res.redirect('/');
    }
});

Why this works:

  • Eliminates URL injection entirely - users provide string keys, not URLs
  • A Map, not a plain object. A plain object's REDIRECT_MAP[dest] also finds inherited names: REDIRECT_MAP['constructor'] is Object.prototype.constructor, which is truthy, and measured on Express 5.2.1 res.redirect() then emitted the function's source text as the Location header. Map.get() returns undefined for every key that was never set, constructor, __proto__ and toString included
  • Invalid keys (like '<script>alert(1)</script>' or '../../etc/passwd') return undefined
  • Fail-closed behavior redirects to / for invalid/missing destination IDs
  • Encoding bypasses, protocol tricks and domain manipulation have nothing to act on: no request value is ever parsed as a URL
  • Easy to audit - review REDIRECT_MAP and keep mapped destinations local or explicitly trusted

Koa Framework Pattern

// SECURE - Koa: same origin check before ctx.redirect()
const Koa = require('koa');
const Router = require('@koa/router');
const { URL } = require('url');

const app = new Koa();
const router = new Router();

const PARSE_BASE = 'http://localhost';

function isLocalUrl(url) {
    if (!url || typeof url !== 'string') {
        return false;
    }

    // Reject control characters: CR or LF in a Location header is a
    // header injection, and browsers delete tab, CR and LF before parsing
    if (/[\u0000-\u001F\u007F]/.test(url)) {
        return false;
    }

    try {
        // Resolve as the browser does, then require the same origin
        return new URL(url, PARSE_BASE).origin === PARSE_BASE
            && url.startsWith('/');
    } catch (e) {
        return false;  // Malformed URL
    }
}

router.get('/login', async (ctx) => {
    // Authenticate...

    const returnUrl = ctx.query.returnUrl;

    if (returnUrl && isLocalUrl(returnUrl)) {
        ctx.redirect(returnUrl);
    } else {
        ctx.redirect('/');
    }
});

app.use(router.routes());

Why this works:

  • The origin comparison is the same check as the Express example above, and for the same reason: a regex over the raw string has to enumerate every external form, while resolving against a base makes the parser do it. /\evil.com is the form that string checks routinely miss
  • startsWith('/') ensures valid relative path
  • Control characters are rejected before parsing, for the reason given under the Express example above
  • Koa's ctx.redirect() works safely with validated relative URLs
  • Fail-closed default redirects to /

Client-Side Safe Redirect

// SECURE - Client-side redirect with validation
function isLocalUrl(url) {
    if (!url || typeof url !== 'string') {
        return false;
    }

    // Browsers delete tab, CR and LF before resolving a URL - reject
    // control characters rather than let the parser repair the value
    if (/[\u0000-\u001F\u007F]/.test(url)) {
        return false;
    }

    try {
        const parsed = new URL(url, window.location.origin);

        // Must be same origin
        return parsed.origin === window.location.origin;

    } catch (e) {
        return false;  // Malformed URL
    }
}

const urlParams = new URLSearchParams(window.location.search);
const redirectUrl = urlParams.get('next');

if (redirectUrl && isLocalUrl(redirectUrl)) {
    window.location.href = redirectUrl;
} else {
    window.location.href = '/';  // Safe default
}

Why this works:

  • new URL(url, window.location.origin) resolves URLs relative to current page origin
  • parsed.origin === window.location.origin performs same-origin check (protocol + hostname + port)
  • Prevents cross-origin redirects including subdomains (unless same origin)
  • Control characters are rejected before parsing, for the reason given under the Express example above
  • window.location.href = url and window.location = url navigate identically - assigning to the Location object runs the same setter as assigning to its href. Neither is the safer form, and neither validates anything; the origin comparison above it is the whole control
  • Try-catch handles malformed URLs
  • Client-side validation adds defense-in-depth (but server-side validation still required)

Warning Page for External URLs

// SECURE - interstitial for allowlisted external destinations
const express = require('express');
const escapeHtml = require('escape-html');   // npm install escape-html
const { URL } = require('url');
const app = express();

const ALLOWED_DOMAINS = new Set(['example.com', 'partner.example.org']);

app.get('/external', (req, res) => {
    const targetUrl = req.query.url;

    if (!targetUrl) {
        return res.redirect('/');
    }

    // Browsers delete tab, CR and LF before resolving a URL, and CR or LF
    // in a Location header is a header injection - reject control characters
    if (/[\u0000-\u001F\u007F]/.test(targetUrl)) {
        return res.redirect('/');
    }

    try {
        const baseUrl = process.env.APP_ORIGIN;
        const parsed = new URL(targetUrl, baseUrl);
        const base = new URL(baseUrl);

        // Check if local - and redirect to the URL that was validated
        if (parsed.origin === base.origin) {
            return res.redirect(parsed.href);
        }

        // Check if allowed external domain
        if ((parsed.protocol === 'https:') &&
            !parsed.username && !parsed.password && !parsed.port &&
            ALLOWED_DOMAINS.has(parsed.hostname.toLowerCase())) {

            // Show warning page. Display and link `parsed.href`, not the raw
            // parameter: the allowlist was checked against `parsed`, so that
            // is the value that was actually validated.
            const safeUrl = parsed.href;

            return res.send(`
                <h2>You are leaving our site</h2>
                <p>You are about to visit: ${escapeHtml(safeUrl)}</p>
                <a href="${escapeHtml(safeUrl)}" rel="noopener noreferrer">Continue to external site</a>
                <a href="/">Stay here</a>
            `);
        }

    } catch (e) {
        // Malformed URL
    }

    res.redirect('/');
});

Why this works:

  • Interstitial warning breaks automatic phishing redirect chain
  • The page shows and links parsed.href rather than the raw url parameter, so the value the user sees and clicks is the one the allowlist checked. Where a validated object and the original string can drift apart, emit the object. The same-origin branch redirects to parsed.href for the same reason, and control characters are rejected before anything is parsed.
  • escape-html encodes the URL for the surrounding markup. It is doing the HTML half of the job only - what makes the href safe is the scheme and host check above it, since HTML encoding leaves a javascript: URL intact
  • Requires explicit user click to proceed to external site
  • Provides clear escape option ("Stay here") for suspicious redirects
  • Only shown for external allowlisted URLs - local redirects go straight through
  • Combines validation with user awareness for defense-in-depth

Common Pitfalls

  • Checking url.startsWith('//') on the original string but not accounting for a leading backslash. A value like /\evil.com isn't caught by that check, and the WHATWG URL parser converts backslashes to slashes for HTTP and HTTPS URLs - so every browser, and Node's own new URL('/\\evil.com', 'http://localhost'), resolves it to http://evil.com/. /%09/evil.com is the same shape one step further out: req.query hands the handler a real tab, and the URL parser deletes tab, CR and LF before parsing, so new URL('/\t/evil.com', 'http://localhost') is http://evil.com/ as well. Whether that reaches the browser depends on the sink - measured on Express 5.2.1, res.redirect() percent-encodes the tab back to %09 and the value stays a path, while a raw res.writeHead(302, { Location: value }) writes it unchanged. Resolving against a base origin and comparing origins rejects both values at the check, which is where you want it decided rather than in whichever sink the next handler happens to use.
  • Validating returnUrl in the Express route handler with isLocalUrl(), while a client-side router.push(returnUrl) or window.location.href = returnUrl reads the same query parameter directly on the frontend - server-side middleware never sees client-side navigation, so the check doesn't apply to it at all.
  • Building the "trusted" comparison base from a request-derived value (req.headers.host or req.get('host')) instead of a fixed, server-configured origin. If the app sits behind a proxy that forwards an unvalidated Host header, an attacker who influences that header makes the "trusted" side of the origin === base.origin comparison attacker-influenced too, not just the redirect target.

Additional Resources