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.returnUrlretrieves user-controlled input directly from URL parametersres.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.nextretrieves user input without validation- Setting
Locationheader directly with user input allows arbitrary redirects - No validation that URL is local to the application
- Missing null check causes
Location: undefinedheader
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:
URLSearchParamsparses user-controlled query stringwindow.locationassignment 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 inputstartsWith('http://')andstartsWith('https://')checks block absolute URLs to external domainsstartsWith('//')check prevents protocol-relative URLs like//evil.comthat browsers interpret ashttps://evil.comstartsWith('/')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/pathtohttp://yourapp.com/pathparsed.hostname === base.hostnameperforms exact hostname matching, rejecting different domains- Handles both relative URLs (
/dashboard) and absolute URLs on same domain (http://yourapp.com/profile) req.protocolandreq.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:
Setprovides O(1) lookup for allowed domains with automatic deduplication- Combines same-origin validation with external domain allowlist for flexibility
parsed.protocolcheck blocks JavaScript URLs (javascript:), data URLs (data:), file URLs (file:)parsed.hostname.toLowerCase()performs case-insensitive matching, preventingExAmPlE.cOmbypasses- 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?:\/\//imatcheshttp://orhttps://case-insensitively, blocking absolute URLs startsWith('//')check prevents protocol-relative URL bypassstartsWith('/')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 originparsed.origin === window.location.originperforms same-origin check (protocol + hostname + port)- Prevents cross-origin redirects including subdomains (unless same origin)
.hrefassignment 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 = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
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