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 proper validation, enabling phishing attacks and credential theft. Express, Koa, and vanilla Node.js HTTP servers all require careful handling of redirect destinations.

Primary Defence: 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

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

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

    try {
        // Parse URL relative to a base
        const parsed = new URL(url, 'http://localhost');

        // Must be relative (no protocol or host in original)
        if (url.startsWith('http://') || 
            url.startsWith('https://') || 
            url.startsWith('//')) {
            return false;
        }

        // Must start with /
        if (!url.startsWith('/')) {
            return false;
        }

        return true;

    } 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, 'http://localhost') parses URLs with proper handling of edge cases, throwing errors for malformed input
  • startsWith('http://') and startsWith('https://') checks block absolute URLs to external domains
  • startsWith('//') check prevents protocol-relative URLs like //evil.com that browsers interpret as https://evil.com
  • startsWith('/') ensures URL is a valid relative path within the application
  • 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 Hostname Validation

const express = require('express');
const { URL } = require('url');
const app = express();

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

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

        // Check if hostname matches (same origin)
        return parsed.hostname === base.hostname;

    } catch (e) {
        return false;
    }
}

app.get('/redirect', (req, res) => {
    const targetUrl = req.query.url;
    const baseUrl = `${req.protocol}://${req.get('host')}`;

    if (targetUrl && isLocalUrl(targetUrl, baseUrl)) {
        res.redirect(targetUrl);
    } 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.hostname === base.hostname performs exact hostname matching, rejecting different domains
  • Handles both relative URLs (/dashboard) and absolute URLs on same domain (http://yourapp.com/profile)
  • req.protocol and req.get('host') dynamically determine application's origin
  • Prevents subdomain bypasses by requiring exact hostname match
  • Try-catch handles malformed URLs safely

Allowlist External Domains

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;
    }

    try {
        const baseUrl = `${req.protocol}://${req.get('host')}`;
        const parsed = new URL(url, baseUrl);
        const base = new URL(baseUrl);

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

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

        // Exact hostname match (case-insensitive)
        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)) {
        res.redirect(targetUrl);
    } else {
        res.redirect('/');
    }
});

Why this works:

  • Set provides O(1) lookup for allowed domains with automatic deduplication
  • Combines same-origin validation with external domain allowlist for flexibility
  • 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 ensures proper handling
  • Handles both relative and absolute URLs correctly with URL parser
  • Fail-closed default redirects to / for invalid/unlisted domains

Indirect Redirects (Best Practice)

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

const REDIRECT_MAP = {
    'dashboard': '/dashboard',
    'profile': '/user/profile',
    'settings': '/user/settings'
};

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

    const redirectUrl = REDIRECT_MAP[dest];

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

Why this works:

  • Eliminates URL injection entirely - users provide string keys, not URLs
  • Object/dictionary lookup is safe with no injection risk
  • Invalid keys (like '<script>alert(1)</script>' or '../../etc/passwd') return undefined
  • Fail-closed behavior redirects to / for invalid/missing destination IDs
  • Immune to encoding bypasses, protocol tricks, and domain manipulation
  • Easiest pattern to security audit - just review the REDIRECT_MAP object

Koa Framework Pattern

const Koa = require('koa');
const Router = require('@koa/router');
const { URL } = require('url');

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

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

    // Reject absolute URLs
    if (url.match(/^https?:\/\//i) || url.startsWith('//')) {
        return false;
    }

    // Must be relative path
    return url.startsWith('/');
}

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:

  • Regex /^https?:\/\//i matches http:// or https:// case-insensitively, blocking absolute URLs
  • startsWith('//') check prevents protocol-relative URL bypass
  • startsWith('/') ensures valid relative path
  • 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;
    }

    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)
  • .href assignment safer than direct = for window.location
  • Try-catch handles malformed URLs
  • Client-side validation adds defense-in-depth (but server-side validation still required)

Warning Page for External URLs

const express = require('express');
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('/');
    }

    try {
        const baseUrl = `${req.protocol}://${req.get('host')}`;
        const parsed = new URL(targetUrl, baseUrl);
        const base = new URL(baseUrl);

        // Check if local
        if (parsed.hostname === base.hostname) {
            return res.redirect(targetUrl);
        }

        // Check if allowed external domain
        if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
            ALLOWED_DOMAINS.has(parsed.hostname.toLowerCase())) {

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

    } catch (e) {
        // Malformed URL
    }

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

function escapeHtml(text) {
    const map = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#039;'
    };
    return text.replace(/[&<>"']/g, m => map[m]);
}

Why this works:

  • Interstitial warning breaks automatic phishing redirect chain
  • escapeHtml() prevents XSS when displaying destination URL
  • 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 are seamless
  • Combines validation with user awareness for defense-in-depth

Additional Resources