What is an Open Redirect?
Every time you click a link and the server sends your browser to a different URL, an HTTP redirect happened. The server says: “don’t stay here, go there” — and the browser complies. That’s completely normal and useful.
The problem starts when the redirect destination comes from user input with no validation. An attacker can then supply any destination — including their own phishing site.
Why is this dangerous in practice?
The victim receives an email: “Click here to access your account”. The link is bank.com/redirect?to=.... Because the domain bank.com looks legitimate, they don’t inspect the full URL and click — the server sends them to an identical phishing site. Email filters, antivirus software, and human instinct are all fooled because the link origin is genuine.
A classic example: a user tries to access /settings without being logged in. The system saves that URL and redirects to it after login. The danger appears when the destination URL comes from an external parameter with no validation:
// After login, redirect to where the user wanted to go
app.get('/after-login', (req, res) => {
const destination = req.query.next;
// ❌ No validation — the attacker controls 'next'
// Attack URL: /after-login?next=http://phishing.com
res.redirect(destination);
});
The obvious solution is to create an allowlist — a list of permitted destination domains. CVE-2024-29041 demonstrates exactly how a malformed URL can bypass that allowlist even when it is correctly implemented.
How Express handles redirects internally
To understand the vulnerability, we need to understand what Express does under the hood when you call res.redirect().
- You call
res.redirect(url): Express receives the destination URL. It can be an absolute URL (http://...) or a relative path (/dashboard). - Internally it calls
res.location(url):res.redirect()is a wrapper that sets theLocation: urlheader and sends the response with status 302. res.location()appliesencodeUrl(url): Before putting the URL in the header, Express runs it through an encoding function. This ensures special characters don’t break the HTTP header — but this is where the problem is born.- The
Locationheader is sent: The browser reads it and navigates to the indicated URL. The final navigation is done by the browser, which may interpret the URL differently from Express and Node.js.
The Node.js server validates and transforms the URL one way. The browser that will navigate may interpret the same string differently — especially for malformed URLs. That gap is where the vulnerability lives.
The encodeUrl() function converts special characters to %XX format accepted in HTTP headers. A space becomes %20, a backslash \ becomes %5C, and so on:
const encodeUrl = require('encodeurl');
encodeUrl('http://google.com/search with space')
// → 'http://google.com/search%20with%20space' ✓
encodeUrl('http://google.com\@evil.com')
// → 'http://google.com%5C@evil.com'
// ^^^
// \ became %5C — this will cause the problem
The vulnerability in detail
Anatomy of a URL
In the URL http://google.com@evil.com, who is the real host? It’s evil.com. The google.com before the @ is treated as the username (userinfo field). Modern browsers ignore that credential field during navigation and go straight to the host — evil.com.
The role of the backslash \
URLs use the forward slash /. The backslash \ is technically invalid in URLs. But modern browsers follow the WHATWG specification, which states:
in HTTP/HTTPS URLs, treat
\as if it were/.
This was done to be more tolerant of malformed URLs in the real web.
That tolerance creates an important difference between the legacy Node.js parser and the browser:
const legacyUrl = require('url');
const malicious = 'http://google.com\\@evil.com';
// url.parse() — legacy Node.js parser (does not normalize \)
legacyUrl.parse(malicious).hostname;
// → 'google.com' ← treats \ as start of path
// new URL() — modern WHATWG parser (same spec browsers use)
new URL(malicious).hostname;
// → 'evil.com' ← normalizes \ to /, so @evil.com is the host!
A developer using url.parse() for validation thinks they are protected. But the browser, which uses WHATWG, navigates somewhere different from what the server validated.
url.parse() is officially deprecated
Node.js marked url.parse() as deprecated (DEP0169, Node.js ≥ 21) precisely because of this kind of unpredictable behavior with malformed URLs. The official replacement is the WHATWG URL API, available globally since Node.js 10, which uses the same specification that modern browsers implement:
// ❌ Deprecated — inconsistent behavior with browsers
require('url').parse(urlString).hostname
// ✅ Modern — same spec as the browser; what you validate is what the browser executes
new URL(urlString).hostnameIf your project uses url.parse() for any security-related validation, replace it with new URL(). The try/catch is required because new URL() throws TypeError for invalid URLs — which is a safer behavior than silently returning null.
How encodeUrl() closes the attack loop
// STEP 1: attacker sends the malicious URL
const input = 'http://google.com\@evil.com';
// STEP 2: allowlist with url.parse lets it through
require('url').parse(input).hostname;
// → 'google.com' ← it's on the allowlist!
// STEP 3: Express applies encodeUrl
encodeUrl(input); // → 'http://google.com%5C@evil.com'
// ^^^
// \ became %5C
// STEP 4: header sent to browser
// Location: http://google.com%5C@evil.com
// STEP 5: browser processes the header
// Decodes: %5C → \
// Normalizes: \ → / (WHATWG spec)
// Interprets: http://google.com/@evil.com
// Real host: evil.com ← the browser goes HERE!
Building the exploit
Vulnerable environment
# Create an isolated project with the vulnerable version
mkdir express-cve-lab && cd express-cve-lab
npm init -y
npm install express@4.19.0 # any version <4.19.0 is vulnerable
// server.js — vulnerable Express with allowlist
const express = require('express');
const urlParser = require('url');
const app = express();
const ALLOWED = ['google.com', 'myapp.com'];
app.get('/redirect', (req, res) => {
const destination = req.query.url;
const host = urlParser.parse(destination).hostname;
if (!ALLOWED.includes(host)) {
return res.status(403).send('Host not allowed: ' + host);
}
res.redirect(destination); // Express calls encodeUrl() here internally
});
app.listen(3000, () => console.log('http://localhost:3000'));
The exploit
# Direct attempt — BLOCKED correctly
curl -v "http://localhost:3000/redirect?url=http://evil.com"
# ← 403: "Host not allowed: evil.com" ✓
# Exploit — BYPASSES the allowlist
# %5C is the encoded \ to place in the query string
curl -v "http://localhost:3000/redirect?url=http://google.com%5C@evil.com"
# ← 302 Found
# ← Location: http://google.com%5C@evil.com ← browser goes to evil.com!
Interactive demo
Simulate the full flow — from the attacker’s input to what the browser receives and where it navigates. Pick a ready-made payload or type your own.
Patch 1: the first fix attempt
After the vulnerability was reported, the Express team released version 4.19.1 with commit 0867302. The idea: “if the problem is that encodeUrl() changes the host, then we compare the host before and after encoding — if it changed, something was wrong with the URL.”
var mime = send.mime; var urlParse = require('url').parse; ... res.location = function location(url) { // ... ('back' logic) ... var lowerLoc = loc.toLowerCase(); var encodedUrl = encodeUrl(loc); if (lowerLoc.indexOf('https://') === 0 || lowerLoc.indexOf('http://') === 0) { try { var parsedUrl = urlParse(loc); var parsedEncodedUrl = urlParse(encodedUrl); if (parsedUrl.host !== parsedEncodedUrl.host) { return this.set('Location', loc); } } catch (e) { return this.set('Location', loc); } } return this.set('Location', encodeUrl(loc)); return this.set('Location', encodedUrl); clique em uma linha com · para ver a anotação
In plain words: before sending the header, Patch 1 compares the host of the original URL with the host of the encoded URL. If it changed — that means there was a \ that encoding transformed dangerously. In that case, it returns the original URL (with the literal \) instead of the encoded version.
Why Patch 2 was needed
After 4.19.1, the team realized the approach had a conceptual flaw: it used url.parse() — the same problematic parser that caused the bug — to try to detect the bug.
Timeline of the three versions
- express@4.19.0 — Mar 2024
First fix attempt, regression identified: encoding of legitimate URLs with special characters in the path stopped working correctly.
- express@4.19.1 — Mar 2024
Regression fix, commit 0867302 (Patch 1). Host comparison mechanism via url.parse() remains.
- Conceptual flaw identified
Team identifies that comparison via url.parse() is not reliable enough. The right solution is not "detect when encoding went wrong" — it is to never encode the part that should not be encoded.
- express@4.19.2 — Mar 2024
Patch 2 — completely different approach, commit 0b74695. Removes all host comparison logic. Uses regex to locate where the host ends and only applies encodeUrl() from there on.
Patch 2 — the definitive fix
The commit 0b74695 completely replaced Patch 1’s logic. Instead of trying to detect post-encoding problems, the patch splits the URL in two and only encodes what is safe to encode.
var urlParse = require('url').parse; ... var schemaAndHostRegExp = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/; ... @@ res.location @@ var loc = String(url); var loc; if (url === 'back') { loc = this.req.get('Referrer') || '/'; } else { loc = String(url); } @@ new encoding logic @@ var m = schemaAndHostRegExp.exec(loc); var pos = m ? m[0].length + 1 : 0; loc = loc.slice(0, pos) + encodeUrl(loc.slice(pos)); return this.set('Location', encodedUrl); return this.set('Location', loc); clique em uma linha com · para ver a anotação
Understanding the regex in detail
var re = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/;
// Reading piece by piece:
// ^ — start of string
// (?:[a-zA-Z][...]*:)? — optional schema: "http:" or "https:" etc.
// \/\/ — the two slashes "//"
// [^\\ \/ \?]+ — HOST: anything that is NOT \, / or ?
// the regex STOPS at the first problematic character!
// Testing with malicious payload:
re.exec('http://google.com\\@evil.com/path')
// m[0] = 'http://google.com' ← stops BEFORE the backslash!
// pos = 17
//
// loc.slice(0, 17) = 'http://google.com' ← host PRESERVED
// loc.slice(17) = '\\@evil.com/path' ← this gets encoded
// encodeUrl(slice) = '%5C@evil.com/path'
// final loc = 'http://google.com%5C@evil.com/path'
//
// The host in the Location header is 'google.com' — no ambiguity for the browser.
Comparing the two fixes
| Aspect | Patch 1 (4.19.1) | Patch 2 (4.19.2) |
|---|---|---|
| Approach | Reactive — detect and undo the problem after encoding | Preventive — never encode where the problem can occur |
| Uses url.parse? | Yes — the same problematic parser | No — removes the dependency entirely |
| Reliability | Depends on url.parse behavior with malformed URLs | Deterministic — well-defined regex, no ambiguity |
| Complexity | Higher — try/catch, two parse calls, string comparison | Lower — one regex, one slice, one encode |
| Core concept | Verify whether the encoding result is correct | Only apply encoding where it is safe to apply |
What Patch 2 fixes (and what it doesn’t):
- ✅ Fixes: the main vector —
encodeUrl()no longer touches the host, so\in the host never becomes%5Cdangerously. - ✅ Keeps: legitimate URLs continue to be encoded correctly in the path and query.
- ✅ Removes: the fragile dependency on legacy
url.parse()— simpler and more robust. - ❌ Does not prevent: passing a malicious URL if the application has no host validation of its own.
- ❌ Does not replace: developer validation.
res.redirect(req.query.url)without checks is still an open redirect, even on Express 4.19.2.
How to develop securely
Even with Express updated to the fixed version, the responsibility for validating redirect destinations is entirely yours.
❌ Never do: user URL directly in redirect
// ❌ Zero validation — full open redirect
app.get('/go', (req, res) => res.redirect(req.query.url));
// ❌ startsWith — bypassable with a fake subdomain
// 'https://myapp.com.evil.com' passes this check!
if (req.query.url.startsWith('https://myapp.com')) { ... }
// ❌ url.parse — vulnerable as we saw in this CVE
const p = require('url').parse(req.query.url);
if (p.hostname === 'myapp.com') { ... }
✅ Correct: validate with new URL()
const ALLOWED_HOSTS = new Set(['myapp.com', 'www.myapp.com']);
function validateRedirect(urlString) {
try {
// ✅ new URL() uses the WHATWG spec — the same one the browser uses
// Normalizes \→/, userinfo, encoding — what you validate is what the browser executes
const parsed = new URL(urlString);
// Only http and https — blocks javascript:, data:, ftp:, etc.
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
// .hostname (without port) — prevents bypass with myapp.com:80@evil.com
return ALLOWED_HOSTS.has(parsed.hostname) ? urlString : null;
} catch (e) {
return null; // new URL() throws TypeError for invalid URLs
}
}
app.get('/redirect', (req, res) => {
const destination = validateRedirect(req.query.url);
if (!destination) return res.status(403).json({ error: 'Destination not allowed' });
res.redirect(destination);
});
✅ Even better: a fixed destination map
// The user passes a KEY, not the URL — impossible to manipulate
const DESTINATIONS = {
'dashboard': '/app/dashboard',
'settings': '/app/settings',
'profile': '/app/profile',
};
app.get('/after-login', (req, res) => {
const destination = DESTINATIONS[req.query.next] || '/';
res.redirect(destination); // User URL never touches the redirect
});
// If you need relative post-login redirects (e.g. /report/42):
function isSafeRelative(url) {
// Only accept paths starting with / but NOT // or /\
// //evil.com would be a protocol-relative URL → navigates to evil.com
return typeof url === 'string' && /^\/[^\/\\]/.test(url);
}
Golden rules:
- Use
new URL()instead ofurl.parse()— the modern parser normalizes the same ambiguities the browser normalizes. - Validate
.hostname, not.host—.hostincludes the port, which allows bypasses likemyapp.com:80@evil.com. - Prefer relative redirects (
/dashboard) — eliminates the entire class of attacks involving external domains. - Use a fixed destination map — the user passes a key, the server picks the URL.
- Keep Express at
≥4.19.2— the framework fix reduces the attack surface, but it does not replace application-level validation.
Summary
| Aspect | Detail |
|---|---|
| CVE | CVE-2024-29041 / GHSA-rv95-896h-c2vc |
| Type | Open Redirect (CWE-601) + Input Validation (CWE-1286) |
| Severity | MODERATE — CVSS 6.1 |
| Affected versions | Express <4.19.2 and 5.0.0-alpha.1 through <5.0.0-beta.3 |
| Root cause | url.parse() and browsers disagree on the host of URLs containing \; encodeUrl() converted \ to %5C in a way the browser would reverse and navigate to a different host |
| Patch 1 (4.19.1) | Compares host before/after encoding via url.parse() — works, but uses the same problematic parser |
| Patch 2 (4.19.2) | Regex locates where the host ends; encodes only the path/query onwards — preventive and without fragile parser dependencies |
| Prevention | Use new URL() for validation, check .hostname, prefer relative paths or a fixed destination map |