Express.js Open Redirect in malformed URLs
Description
Express.js minimalist web framework for node. Versions of Express.js prior to 4.19.0 and all pre-release alpha and beta versions of 5.0 are affected by an open redirect vulnerability using malformed URLs. When a user of Express performs a redirect using a user-provided URL Express performs an encode using `encodeurl` on the contents before passing it to the location header. This can cause malformed URLs to be evaluated in unexpected ways by common redirect allow list implementations in Express applications, leading to an Open Redirect via bypass of a properly implemented allow list. The main method impacted is res.location() but this is also called from within res.redirect(). The vulnerability is fixed in 4.19.2 and 5.0.0-beta.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Express.js prior to 4.19.2 and 5.0.0-beta.3 has an open redirect vulnerability via malformed URLs that bypass allow lists due to improper encoding.
Vulnerability
CVE-2024-29041 is an open redirect vulnerability in Express.js, a minimalist web framework for Node.js. Versions prior to 4.19.2 and all pre-release alpha and beta versions of 5.0 are affected. The root cause lies in how Express handles user-provided URLs in the res.location() method (also called by res.redirect()). Express uses the encodeurl package to encode the URL before setting the Location header. However, this encoding can transform malformed URLs in unexpected ways, allowing an attacker to craft a URL that, after encoding, still passes an application's allow list but redirects to a different host than intended [1][3].
Exploitation
An attacker can supply a specially crafted URL (for example, containing backslash sequences like http://google.com\@attacker.com) that, after encodeurl processing, does not change the host portion but results in a Location header pointing to an attacker-controlled domain. The vulnerability bypasses commonly implemented allow list checks that examine the URL before it is encoded. The original code attempted to parse the URL before and after encoding to detect host changes, but this approach was incomplete and could be circumvented [1][3][4].
Impact
Successful exploitation allows an attacker to redirect users from a trusted domain to an attacker-controlled site. This can be used in phishing campaigns, credential theft, or other social engineering attacks. The vulnerability does not require authentication and can be triggered by any user-provided URL used in a redirect response.
Mitigation
The Express.js team has patched the vulnerability by changing the URL handling logic. Instead of encoding the entire URL, the fix extracts the scheme and host portion using a regular expression (schemaAndHostRegExp), leaves it unencoded, and only applies encodeurl to the remainder of the URL. This prevents alteration of the host that could bypass allow lists. The fix is available in Express 4.19.2 and 5.0.0-beta.3 [1][3][4]. Users should upgrade immediately.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
expressnpm | < 4.19.2 | 4.19.2 |
expressnpm | >= 5.0.0-alpha.1, < 5.0.0-beta.3 | 5.0.0-beta.3 |
Affected products
48- osv-coords47 versionspkg:apk/chainguard/argo-workflow-clipkg:apk/chainguard/argo-workflow-controllerpkg:apk/chainguard/argo-workflow-controller-compatpkg:apk/chainguard/argo-workflow-executorpkg:apk/chainguard/argo-workflow-executor-compatpkg:apk/chainguard/argo-workflowspkg:apk/chainguard/argo-workflows-known-hostspkg:apk/chainguard/argo-workflows-uipkg:apk/chainguard/kubeflow-centraldashboardpkg:apk/chainguard/kubeflow-pipelinespkg:apk/chainguard/kubeflow-pipelines-apiserverpkg:apk/chainguard/kubeflow-pipelines-cache-deployerpkg:apk/chainguard/kubeflow-pipelines-cache-deployer-compatpkg:apk/chainguard/kubeflow-pipelines-cache_serverpkg:apk/chainguard/kubeflow-pipelines-frontendpkg:apk/chainguard/kubeflow-pipelines-metadata-envoy-configpkg:apk/chainguard/kubeflow-pipelines-metadata-writerpkg:apk/chainguard/kubeflow-pipelines-metadata-writer-compatpkg:apk/chainguard/kubeflow-pipelines-persistence_agentpkg:apk/chainguard/kubeflow-pipelines-scheduledworkflowpkg:apk/chainguard/kubeflow-pipelines-viewer-crd-controllerpkg:apk/chainguard/sqlpadpkg:apk/chainguard/sqlpad-compatpkg:apk/wolfi/argo-workflow-clipkg:apk/wolfi/argo-workflow-controllerpkg:apk/wolfi/argo-workflow-controller-compatpkg:apk/wolfi/argo-workflow-executorpkg:apk/wolfi/argo-workflow-executor-compatpkg:apk/wolfi/argo-workflowspkg:apk/wolfi/argo-workflows-known-hostspkg:apk/wolfi/argo-workflows-uipkg:apk/wolfi/kubeflow-centraldashboardpkg:apk/wolfi/kubeflow-pipelinespkg:apk/wolfi/kubeflow-pipelines-apiserverpkg:apk/wolfi/kubeflow-pipelines-cache-deployerpkg:apk/wolfi/kubeflow-pipelines-cache-deployer-compatpkg:apk/wolfi/kubeflow-pipelines-cache_serverpkg:apk/wolfi/kubeflow-pipelines-frontendpkg:apk/wolfi/kubeflow-pipelines-metadata-envoy-configpkg:apk/wolfi/kubeflow-pipelines-metadata-writerpkg:apk/wolfi/kubeflow-pipelines-metadata-writer-compatpkg:apk/wolfi/kubeflow-pipelines-persistence_agentpkg:apk/wolfi/kubeflow-pipelines-scheduledworkflowpkg:apk/wolfi/kubeflow-pipelines-viewer-crd-controllerpkg:apk/wolfi/sqlpadpkg:apk/wolfi/sqlpad-compatpkg:npm/express
< 3.6.5-r2+ 46 more
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 1.8.0-r3
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 7.4.1-r3
- (no CPE)range: < 7.4.1-r3
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 3.6.5-r2
- (no CPE)range: < 1.8.0-r3
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 2.4.0-r9
- (no CPE)range: < 7.4.1-r3
- (no CPE)range: < 7.4.1-r3
- (no CPE)range: < 4.19.2
Patches
20b746953c4bdImproved fix for open redirect allow list bypass
3 files changed · +280 −63
History.md+5 −0 modified@@ -1,3 +1,8 @@ +unreleased +========== + + * Improved fix for open redirect allow list bypass + 4.19.1 / 2024-03-20 ==========
lib/response.js+11 −20 modified@@ -34,7 +34,6 @@ var extname = path.extname; var mime = send.mime; var resolve = path.resolve; var vary = require('vary'); -var urlParse = require('url').parse; /** * Response prototype. @@ -56,6 +55,7 @@ module.exports = res */ var charsetRegExp = /;\s*charset\s*=/; +var schemaAndHostRegExp = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\/[^\\\/\?]+/; /** * Set status `code`. @@ -905,32 +905,23 @@ res.cookie = function (name, value, options) { */ res.location = function location(url) { - var loc = String(url); + var loc; // "back" is an alias for the referrer if (url === 'back') { loc = this.req.get('Referrer') || '/'; + } else { + loc = String(url); } - 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); - // Because this can encode the host, check that we did not change the host - if (parsedUrl.host !== parsedEncodedUrl.host) { - // If the host changes after encodeUrl, return the original url - return this.set('Location', loc); - } - } catch (e) { - // If parse fails, return the original url - return this.set('Location', loc); - } - } + var m = schemaAndHostRegExp.exec(loc); + var pos = m ? m[0].length + 1 : 0; + + // Only encode after host to avoid invalid encoding which can introduce + // vulnerabilities (e.g. `\\` to `%5C`). + loc = loc.slice(0, pos) + encodeUrl(loc.slice(pos)); - // set location - return this.set('Location', encodedUrl); + return this.set('Location', loc); }; /**
test/res.location.js+264 −43 modified@@ -2,6 +2,7 @@ var express = require('../') , request = require('supertest') + , assert = require('assert') , url = require('url'); describe('res', function(){ @@ -45,49 +46,6 @@ describe('res', function(){ .expect(200, done) }) - it('should not encode bad "url"', function (done) { - var app = express() - - app.use(function (req, res) { - // This is here to show a basic check one might do which - // would pass but then the location header would still be bad - if (url.parse(req.query.q).host !== 'google.com') { - res.status(400).end('Bad url'); - } - res.location(req.query.q).end(); - }); - - request(app) - .get('/?q=http://google.com' + encodeURIComponent('\\@apple.com')) - .expect(200) - .expect('Location', 'http://google.com\\@apple.com') - .end(function (err) { - if (err) { - throw err; - } - - // This ensures that our protocol check is case insensitive - request(app) - .get('/?q=HTTP://google.com' + encodeURIComponent('\\@apple.com')) - .expect(200) - .expect('Location', 'HTTP://google.com\\@apple.com') - .end(done) - }); - }); - - it('should not touch already-encoded sequences in "url"', function (done) { - var app = express() - - app.use(function (req, res) { - res.location('https://google.com?q=%A710').end() - }) - - request(app) - .get('/') - .expect('Location', 'https://google.com?q=%A710') - .expect(200, done) - }) - describe('when url is "back"', function () { it('should set location from "Referer" header', function (done) { var app = express() @@ -146,6 +104,79 @@ describe('res', function(){ }) }) + it('should encode data uri', function (done) { + var app = express() + app.use(function (req, res) { + res.location('data:text/javascript,export default () => { }').end(); + }); + + request(app) + .get('/') + .expect('Location', 'data:text/javascript,export%20default%20()%20=%3E%20%7B%20%7D') + .expect(200, done) + }) + + it('should encode data uri', function (done) { + var app = express() + app.use(function (req, res) { + res.location('data:text/javascript,export default () => { }').end(); + }); + + request(app) + .get('/') + .expect('Location', 'data:text/javascript,export%20default%20()%20=%3E%20%7B%20%7D') + .expect(200, done) + }) + + it('should consistently handle non-string input: boolean', function (done) { + var app = express() + app.use(function (req, res) { + res.location(true).end(); + }); + + request(app) + .get('/') + .expect('Location', 'true') + .expect(200, done) + }); + + it('should consistently handle non-string inputs: object', function (done) { + var app = express() + app.use(function (req, res) { + res.location({}).end(); + }); + + request(app) + .get('/') + .expect('Location', '[object%20Object]') + .expect(200, done) + }); + + it('should consistently handle non-string inputs: array', function (done) { + var app = express() + app.use(function (req, res) { + res.location([]).end(); + }); + + request(app) + .get('/') + .expect('Location', '') + .expect(200, done) + }); + + it('should consistently handle empty string input', function (done) { + var app = express() + app.use(function (req, res) { + res.location('').end(); + }); + + request(app) + .get('/') + .expect('Location', '') + .expect(200, done) + }); + + if (typeof URL !== 'undefined') { it('should accept an instance of URL', function (done) { var app = express(); @@ -161,4 +192,194 @@ describe('res', function(){ }); } }) + + describe('location header encoding', function() { + function createRedirectServerForDomain (domain) { + var app = express(); + app.use(function (req, res) { + var host = url.parse(req.query.q, false, true).host; + // This is here to show a basic check one might do which + // would pass but then the location header would still be bad + if (host !== domain) { + res.status(400).end('Bad host: ' + host + ' !== ' + domain); + } + res.location(req.query.q).end(); + }); + return app; + } + + function testRequestedRedirect (app, inputUrl, expected, expectedHost, done) { + return request(app) + // Encode uri because old supertest does not and is required + // to test older node versions. New supertest doesn't re-encode + // so this works in both. + .get('/?q=' + encodeURIComponent(inputUrl)) + .expect('') // No body. + .expect(200) + .expect('Location', expected) + .end(function (err, res) { + if (err) { + console.log('headers:', res.headers) + console.error('error', res.error, err); + return done(err, res); + } + + // Parse the hosts from the input URL and the Location header + var inputHost = url.parse(inputUrl, false, true).host; + var locationHost = url.parse(res.headers['location'], false, true).host; + + assert.strictEqual(locationHost, expectedHost); + + // Assert that the hosts are the same + if (inputHost !== locationHost) { + return done(new Error('Hosts do not match: ' + inputHost + " !== " + locationHost)); + } + + return done(null, res); + }); + } + + it('should not touch already-encoded sequences in "url"', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'https://google.com?q=%A710', + 'https://google.com?q=%A710', + 'google.com', + done + ); + }); + + it('should consistently handle relative urls', function (done) { + var app = createRedirectServerForDomain(null); + testRequestedRedirect( + app, + '/foo/bar', + '/foo/bar', + null, + done + ); + }); + + it('should not encode urls in such a way that they can bypass redirect allow lists', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'http://google.com\\@apple.com', + 'http://google.com\\@apple.com', + 'google.com', + done + ); + }); + + it('should not be case sensitive', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'HTTP://google.com\\@apple.com', + 'HTTP://google.com\\@apple.com', + 'google.com', + done + ); + }); + + it('should work with https', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'https://google.com\\@apple.com', + 'https://google.com\\@apple.com', + 'google.com', + done + ); + }); + + it('should correctly encode schemaless paths', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + '//google.com\\@apple.com/', + '//google.com\\@apple.com/', + 'google.com', + done + ); + }); + + it('should percent encode backslashes in the path', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'https://google.com/foo\\bar\\baz', + 'https://google.com/foo%5Cbar%5Cbaz', + 'google.com', + done + ); + }); + + it('should encode backslashes in the path after the first backslash that triggered path parsing', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'https://google.com\\@app\\l\\e.com', + 'https://google.com\\@app%5Cl%5Ce.com', + 'google.com', + done + ); + }); + + it('should escape header splitting for old node versions', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'http://google.com\\@apple.com/%0d%0afoo:%20bar', + 'http://google.com\\@apple.com/%0d%0afoo:%20bar', + 'google.com', + done + ); + }); + + it('should encode unicode correctly', function (done) { + var app = createRedirectServerForDomain(null); + testRequestedRedirect( + app, + '/%e2%98%83', + '/%e2%98%83', + null, + done + ); + }); + + it('should encode unicode correctly even with a bad host', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'http://google.com\\@apple.com/%e2%98%83', + 'http://google.com\\@apple.com/%e2%98%83', + 'google.com', + done + ); + }); + + it('should work correctly despite using deprecated url.parse', function (done) { + var app = createRedirectServerForDomain('google.com'); + testRequestedRedirect( + app, + 'https://google.com\'.bb.com/1.html', + 'https://google.com\'.bb.com/1.html', + 'google.com', + done + ); + }); + + it('should encode file uri path', function (done) { + var app = createRedirectServerForDomain(''); + testRequestedRedirect( + app, + 'file:///etc\\passwd', + 'file:///etc%5Cpasswd', + '', + done + ); + }); + }); })
0867302ddbdePrevent open redirect allow list bypass due to encodeurl
2 files changed · +64 −2
lib/response.js+19 −1 modified@@ -34,6 +34,7 @@ var extname = path.extname; var mime = send.mime; var resolve = path.resolve; var vary = require('vary'); +var urlParse = require('url').parse; /** * Response prototype. @@ -911,8 +912,25 @@ res.location = function location(url) { loc = this.req.get('Referrer') || '/'; } + 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); + // Because this can encode the host, check that we did not change the host + if (parsedUrl.host !== parsedEncodedUrl.host) { + // If the host changes after encodeUrl, return the original url + return this.set('Location', loc); + } + } catch (e) { + // If parse fails, return the original url + return this.set('Location', loc); + } + } + // set location - return this.set('Location', encodeUrl(loc)); + return this.set('Location', encodedUrl); }; /**
test/res.location.js+45 −1 modified@@ -1,13 +1,27 @@ 'use strict' var express = require('../') - , request = require('supertest'); + , request = require('supertest') + , url = require('url'); describe('res', function(){ describe('.location(url)', function(){ it('should set the header', function(done){ var app = express(); + app.use(function(req, res){ + res.location('http://google.com/').end(); + }); + + request(app) + .get('/') + .expect('Location', 'http://google.com/') + .expect(200, done) + }) + + it('should preserve trailing slashes when not present', function(done){ + var app = express(); + app.use(function(req, res){ res.location('http://google.com').end(); }); @@ -31,6 +45,36 @@ describe('res', function(){ .expect(200, done) }) + it('should not encode bad "url"', function (done) { + var app = express() + + app.use(function (req, res) { + // This is here to show a basic check one might do which + // would pass but then the location header would still be bad + if (url.parse(req.query.q).host !== 'google.com') { + res.status(400).end('Bad url'); + } + res.location(req.query.q).end(); + }); + + request(app) + .get('/?q=http://google.com\\@apple.com') + .expect(200) + .expect('Location', 'http://google.com\\@apple.com') + .end(function (err) { + if (err) { + throw err; + } + + // This ensures that our protocol check is case insensitive + request(app) + .get('/?q=HTTP://google.com\\@apple.com') + .expect(200) + .expect('Location', 'HTTP://google.com\\@apple.com') + .end(done) + }); + }); + it('should not touch already-encoded sequences in "url"', function (done) { var app = express()
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-rv95-896h-c2vcghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-29041ghsaADVISORY
- expressjs.com/en/4x/api.htmlghsax_refsource_MISCWEB
- github.com/expressjs/express/commit/0867302ddbde0e9463d0564fea5861feb708c2ddghsax_refsource_MISCWEB
- github.com/expressjs/express/commit/0b746953c4bd8e377123527db11f9cd866e39f94ghsax_refsource_MISCWEB
- github.com/expressjs/express/pull/5539ghsax_refsource_MISCWEB
- github.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vcghsax_refsource_CONFIRMWEB
- github.com/koajs/koa/issues/1800ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.