Express.js Open Redirect in malformed URLs
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
### Impact Versions of Express.js prior to 4.19.2 and pre-release alpha and beta versions before 5.0.0-beta.3 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`](https://github.com/pillarjs/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()`. ### Patches https://github.com/expressjs/express/commit/0867302ddbde0e9463d0564fea5861feb708c2dd https://github.com/expressjs/express/commit/0b746953c4bd8e377123527db11f9cd866e39f94 An initial fix went out with `express@4.19.0`, we then patched a feature regression in `4.19.1` and added improved handling for the bypass in `4.19.2`. ### Workarounds The fix for this involves pre-parsing the url string with either `require('node:url').parse` or `new URL`. These are steps you can take on your own before passing the user input string to `res.location` or `res.redirect`. ### Resources https://github.com/expressjs/express/pull/5539 https://github.com/koajs/koa/issues/1800 https://expressjs.com/en/4x/api.html#res.location
The fix
Update response.js fix-redirect-security
lib/response.js+4 −0
@@ -911,6 +911,10 @@ res.location = function location(url) {loc = this.req.get('Referrer') || '/';}+if (loc.startsWith('https://') || loc.startsWith('http://')) {+// formatting url again avoid security escapes+loc = new URL(loc).toString()+}// set locationreturn this.set('Location', encodeUrl(loc));};test/res.location.js | 13 +++++++++++++1 file changed, 13 insertions(+)
test/res.location.js+13 −0
@@ -31,6 +31,19 @@ describe('res', function(){.expect(200, done)})+it('should not encode bad "url" and fix-redirect-security', function (done) {+var app = express()++app.use(function (req, res) {+res.location('http://google.com\\@apple.com').end()+})++request(app)+.get('/')+.expect('Location', 'http://google.com/@apple.com')+.expect(200, done)+})+it('should not touch already-encoded sequences in "url"', function (done) {var app = express()
Prevent open redirect allow list bypass due to encodeurl
lib/response.js+19 −1
@@ -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
@@ -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()
Improved fix for open redirect allow list bypass
History.md+5 −0
@@ -1,3 +1,8 @@+unreleased+==========++* Improved fix for open redirect allow list bypass+4.19.1 / 2024-03-20==========
lib/response.js+11 −20
@@ -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 referrerif (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
@@ -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+);+});+});})
References
- WEBhttps://github.com/expressjs/express/security/advisories/GHSA-rv95-896h-c2vc
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2024-29041
- WEBhttps://github.com/koajs/koa/issues/1800
- WEBhttps://github.com/expressjs/express/pull/5539
- WEBhttps://github.com/expressjs/express/commit/0867302ddbde0e9463d0564fea5861feb708c2dd
- WEBhttps://github.com/expressjs/express/commit/0b746953c4bd8e377123527db11f9cd866e39f94
- WEBhttps://expressjs.com/en/4x/api.html#res.location
- PACKAGEhttps://github.com/expressjs/express