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.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
// 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 aLocationheader, so the check sees the destination the browser will see rather than the string the attacker sent- Comparing
parsed.originagainst the base origin rejects every external form in one test:https://evil.com, the protocol-relative//evil.com, and/\evil.com- which resolves tohttp://evil.com/because the URL parser converts backslashes to slashes for HTTP URLs. AstartsWith('//')test on the raw string misses that last one javascript:anddata: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.comfail the origin test on its own - but the same deletion turns/p\r\nSet-Cookie: a=binto 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 rawres.writeHead(302, { Location: value })throwsERR_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 likedashboard. It also closes a case the origin test alone does not: with a base whose scheme matches, a scheme with a missing slash such ashttp:/evil.comresolves relative to the base and passes the origin comparison - measured on Node 24,new URL('http:/evil.com', 'http://localhost')ishttp://localhost/evil.com- and a browser on anhttpspage resolving the same rawLocationreads it ashttp://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/pathtohttp://yourapp.com/pathparsed.origin === base.originrequires 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 anhttpsbase,https:/evil.comresolves tohttps://yourapp.com/evil.comand 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 theLocationheader for every client and proxy to resolve by its own rules. Emittinghrefsends 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_ORIGINshould be configured server-side; do not derive the trusted origin from an unvalidatedHostheader- 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:
Setprovides 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.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 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'sREDIRECT_MAP[dest]also finds inherited names:REDIRECT_MAP['constructor']isObject.prototype.constructor, which is truthy, and measured on Express 5.2.1res.redirect()then emitted the function's source text as theLocationheader.Map.get()returnsundefinedfor every key that was never set,constructor,__proto__andtoStringincluded - 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_MAPand 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.comis 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 originparsed.origin === window.location.originperforms 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 = urlandwindow.location = urlnavigate identically - assigning to theLocationobject runs the same setter as assigning to itshref. 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.hrefrather than the rawurlparameter, 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 toparsed.hreffor the same reason, and control characters are rejected before anything is parsed. escape-htmlencodes the URL for the surrounding markup. It is doing the HTML half of the job only - what makes thehrefsafe is the scheme and host check above it, since HTML encoding leaves ajavascript: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.comisn'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 ownnew URL('/\\evil.com', 'http://localhost'), resolves it tohttp://evil.com/./%09/evil.comis the same shape one step further out:req.queryhands the handler a real tab, and the URL parser deletes tab, CR and LF before parsing, sonew URL('/\t/evil.com', 'http://localhost')ishttp://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%09and the value stays a path, while a rawres.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
returnUrlin the Express route handler withisLocalUrl(), while a client-siderouter.push(returnUrl)orwindow.location.href = returnUrlreads 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.hostorreq.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 theorigin === base.origincomparison attacker-influenced too, not just the redirect target.