VYPR
Moderate severityNVD Advisory· Published Mar 25, 2024· Updated Aug 2, 2024

Express.js Open Redirect in malformed URLs

CVE-2024-29041

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.

PackageAffected versionsPatched versions
expressnpm
< 4.19.24.19.2
expressnpm
>= 5.0.0-alpha.1, < 5.0.0-beta.35.0.0-beta.3

Affected products

48

Patches

2
0b746953c4bd

Improved fix for open redirect allow list bypass

https://github.com/expressjs/expressWes ToddMar 21, 2024via ghsa
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
    +      );
    +    });
    +  });
     })
    
0867302ddbde

Prevent open redirect allow list bypass due to encodeurl

https://github.com/expressjs/expressFDrag0nMar 15, 2024via ghsa
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

News mentions

0

No linked articles in our index yet.