CVE-2021-3664: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: url-parse is vulnerable to URL Redirection to Untrusted Site
CWE description: A web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a Redirect. This simplifies phishing attacks.
CVSS v3 base score: 5.3
Published: 2021-07-26T12:15Z
Repository: https://github.com/unshiftio/url-parse
Language: JavaScript
Files changed: index.js, test/test.js
Commit date: 2021-07-24 18:28:24 +0200
Commit message: [fix] Ignore slashes after the protocol for special URLs

Fixes #205
Fixes #206

Diff: diff --git a/index.js b/index.js
index 94e357e..f424acc 100644
--- a/index.js
+++ b/index.js
@@ -96,10 +96,28 @@ function lolcation(loc) {
   }
 
   return finaldestination;
 }
 
+/**
+ * Check whether a protocol scheme is special.
+ *
+ * @param {String} The protocol scheme of the URL
+ * @return {Boolean} `true` if the protocol scheme is special, else `false`
+ * @private
+ */
+function isSpecial(scheme) {
+  return (
+    scheme === 'file:' ||
+    scheme === 'ftp:' ||
+    scheme === 'http:' ||
+    scheme === 'https:' ||
+    scheme === 'ws:' ||
+    scheme === 'wss:'
+  );
+}
+
 /**
  * @typedef ProtocolExtract
  * @type Object
  * @property {String} protocol Protocol matched in the URL, in lowercase.
  * @property {Boolean} slashes `true` if protocol is followed by "//", else `false`.
@@ -108,20 +126,36 @@ function lolcation(loc) {
 
 /**
  * Extract protocol information from a URL with/without double slash ("//").
  *
  * @param {String} address URL we want to extract from.
+ * @param {Object} location
  * @return {ProtocolExtract} Extracted information.
  * @private
  */
-function extractProtocol(address) {
+function extractProtocol(address, location) {
   address = trimLeft(address);
+  location = location || {};
 
-  var match = protocolre.exec(address)
-    , protocol = match[1] ? match[1].toLowerCase() : ''
-    , slashes = !!(match[2] && match[2].length >= 2)
-    , rest =  match[2] && match[2].length === 1 ? '/' + match[3] : match[3];
+  var match = protocolre.exec(address);
+  var protocol = match[1] ? match[1].toLowerCase() : '';
+  var rest = match[2] ? match[2] + match[3] : match[3];
+  var slashes = !!(match[2] && match[2].length >= 2);
+
+  if (protocol === 'file:') {
+    if (slashes) {
+      rest = rest.slice(2);
+    }
+  } else if (isSpecial(protocol)) {
+    rest = match[3];
+  } else if (protocol) {
+    if (rest.indexOf('//') === 0) {
+      rest = rest.slice(2);
+    }
+  } else if (slashes && location.hostname) {
+    rest = match[3];
+  }
 
   return {
     protocol: protocol,
     slashes: slashes,
     rest: rest
@@ -212,21 +246,24 @@ function Url(address, location, parser) {
   location = lolcation(location);
 
   //
   // Extract protocol information before running the instructions.
   //
-  extracted = extractProtocol(address || '');
+  extracted = extractProtocol(address || '', location);
   relative = !extracted.protocol && !extracted.slashes;
   url.slashes = extracted.slashes || relative && location.slashes;
   url.protocol = extracted.protocol || location.protocol || '';
   address = extracted.rest;
 
   //
   // When the authority component is absent the URL starts with a path
   // component.
   //
-  if (!extracted.slashes || url.protocol === 'file:') {
+  if (
+    url.protocol === 'file:' ||
+    (!extracted.slashes && !isSpecial(extracted.protocol))
+  ) {
     instructions[3] = [/(.*)/, 'pathname'];
   }
 
   for (; i < instructions.length; i++) {
     instruction = instructions[i];
diff --git a/test/test.js b/test/test.js
index 38290ed..9a84fba 100644
--- a/test/test.js
+++ b/test/test.js
@@ -91,11 +91,11 @@ describe('url-parse', function () {
       });
 
       assume(parse.extractProtocol('//foo/bar')).eql({
         slashes: true,
         protocol: '',
-        rest: 'foo/bar'
+        rest: '//foo/bar'
       });
     });
 
     it('does not truncate the input string', function () {
       var input = 'foo\nbar\rbaz\u2028qux\u2029';
@@ -281,24 +281,72 @@ describe('url-parse', function () {
 
     assume(parsed.host).equals('what-is-up.com');
     assume(parsed.href).equals('http://what-is-up.com/');
   });
 
-  it('does not see a slash after the protocol as path', function () {
+  it('ignores slashes after the protocol for special URLs', function () {
     var url = 'https:\\/github.com/foo/bar'
       , parsed = parse(url);
 
     assume(parsed.host).equals('github.com');
     assume(parsed.hostname).equals('github.com');
     assume(parsed.pathname).equals('/foo/bar');
 
     url = 'https:/\\/\\/\\github.com/foo/bar';
+    parsed = parse(url);
     assume(parsed.host).equals('github.com');
     assume(parsed.hostname).equals('github.com');
     assume(parsed.pathname).equals('/foo/bar');
+
+    url = 'https:/github.com/foo/bar';
+    parsed = parse(url);
+    assume(parsed.host).equals('github.com');
+    assume(parsed.pathname).equals('/foo/bar');
+
+    url = 'https:\\github.com/foo/bar';
+    parsed = parse(url);
+    assume(parsed.host).equals('github.com');
+    assume(parsed.pathname).equals('/foo/bar');
+
+    url = 'https:github.com/foo/bar';
+    parsed = parse(url);
+    assume(parsed.host).equals('github.com');
+    assume(parsed.pathname).equals('/foo/bar');
+
+    url = 'https:github.com/foo/bar';
+    parsed = parse(url);
+    assume(parsed.host).equals('github.com');
+    assume(parsed.pathname).equals('/foo/bar');
   });
 
+  it('handles slashes after the protocol for non special URLs', function () {
+    var url = 'foo:example.com'
+      , parsed = parse(url);
+
+    assume(parsed.hostname).equals('');
+    assume(parsed.pathname).equals('example.com');
+    assume(parsed.href).equals('foo:example.com');
+
+    url = 'foo:/example.com';
+    parsed = parse(url);
+    assume(parsed.hostname).equals('');
+    assume(parsed.pathname).equals('/example.com');
+    assume(parsed.href).equals('foo:/example.com');
+
+    url = 'foo://example.com';
+    parsed = parse(url);
+    assume(parsed.hostname).equals('example.com');
+    assume(parsed.pathname).equals('/');
+    assume(parsed.href).equals('foo://example.com/');
+
+    url = 'foo:///example.com';
+    parsed = parse(url);
+    assume(parsed.hostname).equals('');
+    assume(parsed.pathname).equals('/example.com');
+    assume(parsed.href).equals('foo:///example.com');
+  })
+
   describe('origin', function () {
     it('generates an origin property', function () {
       var url = 'http://google.com:80/pathname'
         , parsed = parse(url);
 
@@ -438,21 +486,33 @@ describe('url-parse', function () {
       data.set('protocol', 'https:');
       assume(data.href).equals('https://google.com/foo');
     });
 
     it('handles the file: protocol', function () {
-      var slashes = ['', '/', '//', '///', '////', '/////'];
+      var slashes = ['', '/', '//', '///'];
       var data;
       var url;
 
       for (var i = 0; i < slashes.length; i++) {
         data = parse('file:' + slashes[i]);
         assume(data.protocol).equals('file:');
         assume(data.pathname).equals('/');
         assume(data.href).equals('file:///');
       }
 
+      url = 'file:////';
+      data = parse(url);
+      assume(data.protocol).equals('file:');
+      assume(data.pathname).equals('//');
+      assume(data.href).equals(url);
+
+      url = 'file://///';
+      data = parse(url);
+      assume(data.protocol).equals('file:');
+      assume(data.pathname).equals('///');
+      assume(data.href).equals(url);
+
       url = 'file:///Users/foo/BAR/baz.pdf';
       data = parse(url);
       assume(data.protocol).equals('file:');
       assume(data.pathname).equals('/Users/foo/BAR/baz.pdf');
       assume(data.href).equals(url);

Vulnerable code: function extractProtocol(address) {
  var match = protocolre.exec(address)
    , protocol = match[1] ? match[1].toLowerCase() : ''
    , slashes = !!(match[2] && match[2].length >= 2)
    , rest =  match[2] && match[2].length === 1 ? '/' + match[3] : match[3];
  extracted = extractProtocol(address || '');
  if (!extracted.slashes || url.protocol === 'file:') {
        rest: 'foo/bar'
  it('does not see a slash after the protocol as path', function () {
      var slashes = ['', '/', '//', '///', '////', '/////'];
Fixed code: /**
 * Check whether a protocol scheme is special.
 *
 * @param {String} The protocol scheme of the URL
 * @return {Boolean} `true` if the protocol scheme is special, else `false`
 * @private
 */
function isSpecial(scheme) {
  return (
    scheme === 'file:' ||
    scheme === 'ftp:' ||
    scheme === 'http:' ||
    scheme === 'https:' ||
    scheme === 'ws:' ||
    scheme === 'wss:'
  );
}

 * @param {Object} location
function extractProtocol(address, location) {
  location = location || {};
  var match = protocolre.exec(address);
  var protocol = match[1] ? match[1].toLowerCase() : '';
  var rest = match[2] ? match[2] + match[3] : match[3];
  var slashes = !!(match[2] && match[2].length >= 2);

  if (protocol === 'file:') {
    if (slashes) {
      rest = rest.slice(2);
    }
  } else if (isSpecial(protocol)) {
    rest = match[3];
  } else if (protocol) {
    if (rest.indexOf('//') === 0) {
      rest = rest.slice(2);
    }
  } else if (slashes && location.hostname) {
    rest = match[3];
  }
  extracted = extractProtocol(address || '', location);
  if (
    url.protocol === 'file:' ||
    (!extracted.slashes && !isSpecial(extracted.protocol))
  ) {
        rest: '//foo/bar'
  it('ignores slashes after the protocol for special URLs', function () {
    parsed = parse(url);

    url = 'https:/github.com/foo/bar';
    parsed = parse(url);
    assume(parsed.host).equals('github.com');
    assume(parsed.pathname).equals('/foo/bar');

    url = 'https:\\github.com/foo/bar';
    parsed = parse(url);
    assume(parsed.host).equals('github.com');
    assume(parsed.pathname).equals('/foo/bar');

    url = 'https:github.com/foo/bar';
    parsed = parse(url);
    assume(parsed.host).equals('github.com');
    assume(parsed.pathname).equals('/foo/bar');

    url = 'https:github.com/foo/bar';
    parsed = parse(url);
    assume(parsed.host).equals('github.com');
    assume(parsed.pathname).equals('/foo/bar');
  it('handles slashes after the protocol for non special URLs', function () {
    var url = 'foo:example.com'
      , parsed = parse(url);

    assume(parsed.hostname).equals('');
    assume(parsed.pathname).equals('example.com');
    assume(parsed.href).equals('foo:example.com');

    url = 'foo:/example.com';
    parsed = parse(url);
    assume(parsed.hostname).equals('');
    assume(parsed.pathname).equals('/example.com');
    assume(parsed.href).equals('foo:/example.com');

    url = 'foo://example.com';
    parsed = parse(url);
    assume(parsed.hostname).equals('example.com');
    assume(parsed.pathname).equals('/');
    assume(parsed.href).equals('foo://example.com/');

    url = 'foo:///example.com';
    parsed = parse(url);
    assume(parsed.hostname).equals('');
    assume(parsed.pathname).equals('/example.com');
    assume(parsed.href).equals('foo:///example.com');
  })

      var slashes = ['', '/', '//', '///'];
      url = 'file:////';
      data = parse(url);
      assume(data.protocol).equals('file:');
      assume(data.pathname).equals('//');
      assume(data.href).equals(url);

      url = 'file://///';
      data = parse(url);
      assume(data.protocol).equals('file:');
      assume(data.pathname).equals('///');
      assume(data.href).equals(url);