CVE-2022-0122: URL Redirection to Untrusted Site ('Open Redirect') (CWE-601)
value: forge 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: 6.1
Published: 2022-01-06T05:15Z
Repository: https://github.com/digitalbazaar/forge
Language: JavaScript
Files changed: CHANGELOG.md, README.md, lib/http.js, lib/util.js, lib/xhr.js, package.json, tests/websockets/server-webid.js
Commit date: 2022-01-04 18:37:20 -0500
Commit message: Remove forge.util.parseUrl.

- Switch URL parsing to the WHATWG URL Standard `URL` API.
- Older browser or Node.js usage of related code might now require a URL
  polyfill.

Diff: diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3071b09..0bd63e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,24 +7,40 @@ Forge ChangeLog
 - **SECURITY**, **BREAKING**: Remove `forge.debug` API. The API has the
   potential for prototype pollution. This API was only briefly used by the
   maintainers for internal project debug purposes and was never intended to be
   used with untrusted user inputs. This API was not documented or advertised
   and is being removed rather than fixed.
+- **SECURITY**, **BREAKING**: Remove `forge.util.parseUrl()` (and
+  `forge.http.parseUrl` alias) and use the [WHATWG URL
+  Standard](https://url.spec.whatwg.org/). `URL` is supported by modern browers
+  and modern Node.js. This change is needed to address URL parsing security
+  issues. If `forge.util.parseUrl()` is used directly or through `forge.xhr` or
+  `forge.http` APIs, and support is needed for environments without `URL`
+  support, then a polyfill must be used.
 - **BREAKING**: Remove `forge.task` API. This API was never used, documented,
   or advertised by the maintainers. If anyone was using this API and wishes to
   continue development it in other project, please let the maintainers know.
   Due to use in the test suite, a modified version is located in
   `tests/support/`.
 
+### Changed
+- **BREAKING**: Increase supported Node.js version to 6.13.0 for URL support.
+
 ### Added
 - OIDs for `surname`, `title`, and `givenName`.
 
 ### Fixed
 - **BREAKING**: OID 2.5.4.5 name fixed from `serialName` to `serialNumber`.
   Depending on how applications used this id to name association it could cause
   compatibility issues.
 
+### Notes
+- The URL related changes may expose bugs in some of the networking related
+  code (unrelated to the much wider used cryptography code). The automated and
+  manual test coverage for this code is weak at best. Issues or patches to
+  update the code or tests would be appriciated.
+
 ## 0.10.0 - 2020-09-01
 
 ### Changed
 - **BREAKING**: Node.js 4 no longer supported. The code *may* still work, and
   non-invasive patches to keep it working will be considered. However, more
diff --git a/README.md b/README.md
index c308ebe..bddcffe 100644
--- a/README.md
+++ b/README.md
@@ -1966,14 +1966,10 @@ var nodeBuffer = Buffer.from(forgeBuffer.getBytes(), 'binary');
 
 // convert a Node.js Buffer into a forge buffer
 // make sure you specify the encoding as 'binary'
 var nodeBuffer = Buffer.from('CAFE', 'hex');
 var forgeBuffer = forge.util.createBuffer(nodeBuffer.toString('binary'));
-
-// parse a URL
-var parsed = forge.util.parseUrl('http://example.com/foo?bar=baz');
-// parsed.scheme, parsed.host, parsed.port, parsed.path, parsed.fullHost
 ```
 
 <a name="log" />
 
 ### Logging
diff --git a/lib/http.js b/lib/http.js
index 0ae8630..fe52986 100644
--- a/lib/http.js
+++ b/lib/http.js
@@ -31,12 +31,12 @@ var _normalize = function(name) {
 var _getStorageId = function(client) {
   // TODO: include browser in ID to avoid sharing cookies between
   // browsers (if this is undesirable)
   // navigator.userAgent
   return 'forge.http.' +
-    client.url.scheme + '.' +
-    client.url.host + '.' +
+    client.url.protocol.slice(0, -1) + '.' +
+    client.url.hostname + '.' +
     client.url.port;
 };
 
 /**
  * Loads persistent cookies from disk for the given client.
@@ -119,11 +119,11 @@ var _doRequest = function(client, socket) {
     });
   } else {
     // connect
     socket.options.request.connectTime = +new Date();
     socket.connect({
-      host: client.url.host,
+      host: client.url.hostname,
       port: client.url.port,
       policyPort: client.policyPort,
       policyUrl: client.policyUrl
     });
   }
@@ -308,11 +308,11 @@ var _initSocket = function(client, socket, tlsOptions) {
     client.sockets.push(socket);
     if(tlsOptions.prime) {
       // prime socket by connecting and caching TLS session, will do
       // next request from there
       socket.connect({
-        host: client.url.host,
+        host: client.url.hostname,
         port: client.url.port,
         policyPort: client.policyPort,
         policyUrl: client.policyUrl
       });
     } else {
@@ -403,11 +403,11 @@ var _readCookies = function(client, response) {
  * Creates an http client that uses forge.net sockets as a backend and
  * forge.tls for security.
  *
  * @param options:
  *   url: the url to connect to (scheme://host:port).
- *     socketPool: the flash socket pool to use.
+ *   socketPool: the flash socket pool to use.
  *   policyPort: the flash policy port to use (if other than the
  *     socket pool default), use 0 for flash default.
  *   policyUrl: the flash policy file URL to use (if provided will
  *     be used instead of a policy port).
  *   connections: number of connections to use to handle requests.
@@ -439,12 +439,14 @@ http.createClient = function(options) {
   }
 
   // get scheme, host, and port from url
   options.url = (options.url ||
     window.location.protocol + '//' + window.location.host);
-  var url = http.parseUrl(options.url);
-  if(!url) {
+  var url;
+  try {
+    url = new URL(options.url);
+  } catch(e) {
     var error = new Error('Invalid url.');
     error.details = {url: options.url};
     throw error;
   }
 
@@ -467,11 +469,11 @@ http.createClient = function(options) {
     // all sockets
     sockets: [],
     // idle sockets
     idle: [],
     // whether or not the connections are secure
-    secure: (url.scheme === 'https'),
+    secure: (url.protocol === 'https:'),
     // cookie jar (key'd off of name and then path, there is only 1 domain
     // and one setting for secure per client so name+path is unique)
     cookies: {},
     // default to flash storage of cookies
     persistCookies: (typeof(options.persistCookies) === 'undefined') ?
@@ -495,11 +497,11 @@ http.createClient = function(options) {
    */
   var _defaultCertificateVerify = function(c, verified, depth, certs) {
     if(depth === 0 && verified === true) {
       // compare common name to url host
       var cn = certs[depth].subject.getField('CN');
-      if(cn === null || client.url.host !== cn.value) {
+      if(cn === null || client.url.hostname !== cn.value) {
         verified = {
           message: 'Certificate common name does not match url host.'
         };
       }
     }
@@ -510,11 +512,11 @@ http.createClient = function(options) {
   var tlsOptions = null;
   if(client.secure) {
     tlsOptions = {
       caStore: caStore,
       cipherSuites: options.cipherSuites || null,
-      virtualHost: options.virtualHost || url.host,
+      virtualHost: options.virtualHost || url.hostname,
       verify: options.verify || _defaultCertificateVerify,
       getCertificate: options.getCertificate || null,
       getPrivateKey: options.getPrivateKey || null,
       getSignature: options.getSignature || null,
       prime: options.primeTlsSockets || false
@@ -550,11 +552,11 @@ http.createClient = function(options) {
    *          error: a callback for if an error occurs.
    */
   client.send = function(options) {
     // add host header if not set
     if(options.request.getField('Host') === null) {
-      options.request.setField('Host', client.url.fullHost);
+      options.request.setField('Host', client.url.origin);
     }
 
     // set default dummy handlers
     var opts = {};
     opts.request = options.request;
@@ -1305,19 +1307,10 @@ http.createResponse = function() {
   };
 
   return response;
 };
 
-/**
- * Parses the scheme, host, and port from an http(s) url.
- *
- * @param str the url string.
- *
- * @return the parsed url object or null if the url is invalid.
- */
-http.parseUrl = forge.util.parseUrl;
-
 /**
  * Returns true if the given url is within the given cookie's domain.
  *
  * @param url the url to check.
  * @param cookie the cookie or cookie domain to check.
@@ -1334,15 +1327,15 @@ http.withinCookieDomain = function(url, cookie) {
     rval = true;
   } else if(domain.charAt(0) === '.') {
     // ensure domain starts with a '.'
     // parse URL as necessary
     if(typeof url === 'string') {
-      url = http.parseUrl(url);
+      url = new URL(url);
     }
 
-    // add '.' to front of URL host to match against domain
-    var host = '.' + url.host;
+    // add '.' to front of URL hostname to match against domain
+    var host = '.' + url.hostname;
 
     // if the host ends with domain then it falls within it
     var idx = host.lastIndexOf(domain);
     if(idx !== -1 && (idx + domain.length === host.length)) {
       rval = true;
diff --git a/lib/util.js b/lib/util.js
index 98dfd34..5100eab 100644
--- a/lib/util.js
+++ b/lib/util.js
@@ -2256,47 +2256,10 @@ util.removeItem = function(api, id, key, location) {
  */
 util.clearItems = function(api, id, location) {
   _callStorageFunction(_clearItems, arguments, location);
 };
 
-/**
- * Parses the scheme, host, and port from an http(s) url.
- *
- * @param str the url string.
- *
- * @return the parsed url object or null if the url is invalid.
- */
-util.parseUrl = function(str) {
-  // FIXME: this regex looks a bit broken
-  var regex = /^(https?):\/\/([^:&^\/]*):?(\d*)(.*)$/g;
-  regex.lastIndex = 0;
-  var m = regex.exec(str);
-  var url = (m === null) ? null : {
-    full: str,
-    scheme: m[1],
-    host: m[2],
-    port: m[3],
-    path: m[4]
-  };
-  if(url) {
-    url.fullHost = url.host;
-    if(url.port) {
-      if(url.port !== 80 && url.scheme === 'http') {
-        url.fullHost += ':' + url.port;
-      } else if(url.port !== 443 && url.scheme === 'https') {
-        url.fullHost += ':' + url.port;
-      }
-    } else if(url.scheme === 'http') {
-      url.port = 80;
-    } else if(url.scheme === 'https') {
-      url.port = 443;
-    }
-    url.full = url.scheme + '://' + url.fullHost;
-  }
-  return url;
-};
-
 /* Storage for query variables */
 var _queryVariables = null;
 
 /**
  * Returns the window location query variables. Query is parsed on the first
diff --git a/lib/xhr.js b/lib/xhr.js
index e493c3b..fa92835 100644
--- a/lib/xhr.js
+++ b/lib/xhr.js
@@ -149,11 +149,11 @@ xhrApi.init = function(options) {
     verify: options.verify,
     getCertificate: options.getCertificate,
     getPrivateKey: options.getPrivateKey,
     getSignature: options.getSignature
   });
-  _clients[_client.url.full] = _client;
+  _clients[_client.url.origin] = _client;
 
   forge.log.debug(cat, 'ready');
 };
 
 /**
@@ -378,22 +378,24 @@ xhrApi.create = function(options) {
   // determine which http client to use
   if(options.url === null) {
     // use default
     _state.client = _client;
   } else {
-    var url = http.parseUrl(options.url);
-    if(!url) {
+    var url;
+    try {
+      url = new URL(options.url);
+    } catch(e) {
       var error = new Error('Invalid url.');
       error.details = {
         url: options.url
       };
     }
 
     // find client
-    if(url.full in _clients) {
+    if(url.origin in _clients) {
       // client found
-      _state.client = _clients[url.full];
+      _state.client = _clients[url.origin];
     } else {
       // create client
       _state.client = http.createClient({
         url: options.url,
         socketPool: _sp,
@@ -407,11 +409,11 @@ xhrApi.create = function(options) {
         verify: options.verify,
         getCertificate: options.getCertificate,
         getPrivateKey: options.getPrivateKey,
         getSignature: options.getSignature
       });
-      _clients[url.full] = _state.client;
+      _clients[url.origin] = _state.client;
     }
   }
 
   /**
    * Opens the request. This method will create the HTTP request to send.
diff --git a/package.json b/package.json
index f54ff72..22ff40e 100644
--- a/package.json
+++ b/package.json
@@ -58,11 +58,11 @@
     "flash/swf/*.swf",
     "dist/*.min.js",
     "dist/*.min.js.map"
   ],
   "engines": {
-    "node": ">= 6.0.0"
+    "node": ">= 6.13.0"
   },
   "keywords": [
     "aes",
     "asn",
     "asn.1",
diff --git a/tests/websockets/server-webid.js b/tests/websockets/server-webid.js
index 6f7cf37..5319372 100644
--- a/tests/websockets/server-webid.js
+++ b/tests/websockets/server-webid.js
@@ -172,13 +172,14 @@ var fetchUrl = function(url, callback, redirects) {
   }
 
   console.log('Fetching URL: \"' + url + '\"');
 
   // parse URL
-  url = forge.util.parseUrl(url);
-  var client = http.createClient(
-    url.port, url.fullHost, url.scheme === 'https');
+  url = new URL(url);
+  var client = http.createClient({
+    url: url
+  });
   var request = client.request('GET', url.path, {
     Host: url.host,
     Accept: 'application/rdf+xml'
   });
   request.addListener('response', function(response) {

Vulnerable code: 
// parse a URL
var parsed = forge.util.parseUrl('http://example.com/foo?bar=baz');
// parsed.scheme, parsed.host, parsed.port, parsed.path, parsed.fullHost
    client.url.scheme + '.' +
    client.url.host + '.' +
      host: client.url.host,
        host: client.url.host,
 *     socketPool: the flash socket pool to use.
  var url = http.parseUrl(options.url);
  if(!url) {
    secure: (url.scheme === 'https'),
      if(cn === null || client.url.host !== cn.value) {
      virtualHost: options.virtualHost || url.host,
      options.request.setField('Host', client.url.fullHost);
/**
 * Parses the scheme, host, and port from an http(s) url.
 *
 * @param str the url string.
 *
 * @return the parsed url object or null if the url is invalid.
 */
http.parseUrl = forge.util.parseUrl;

      url = http.parseUrl(url);
    // add '.' to front of URL host to match against domain
    var host = '.' + url.host;
/**
 * Parses the scheme, host, and port from an http(s) url.
 *
 * @param str the url string.
 *
 * @return the parsed url object or null if the url is invalid.
 */
util.parseUrl = function(str) {
  // FIXME: this regex looks a bit broken
  var regex = /^(https?):\/\/([^:&^\/]*):?(\d*)(.*)$/g;
  regex.lastIndex = 0;
  var m = regex.exec(str);
  var url = (m === null) ? null : {
    full: str,
    scheme: m[1],
    host: m[2],
    port: m[3],
    path: m[4]
  };
  if(url) {
    url.fullHost = url.host;
    if(url.port) {
      if(url.port !== 80 && url.scheme === 'http') {
        url.fullHost += ':' + url.port;
      } else if(url.port !== 443 && url.scheme === 'https') {
        url.fullHost += ':' + url.port;
      }
    } else if(url.scheme === 'http') {
      url.port = 80;
    } else if(url.scheme === 'https') {
      url.port = 443;
    }
    url.full = url.scheme + '://' + url.fullHost;
  }
  return url;
};

  _clients[_client.url.full] = _client;
    var url = http.parseUrl(options.url);
    if(!url) {
    if(url.full in _clients) {
      _state.client = _clients[url.full];
      _clients[url.full] = _state.client;
    "node": ">= 6.0.0"
  url = forge.util.parseUrl(url);
  var client = http.createClient(
    url.port, url.fullHost, url.scheme === 'https');
Fixed code: - **SECURITY**, **BREAKING**: Remove `forge.util.parseUrl()` (and
  `forge.http.parseUrl` alias) and use the [WHATWG URL
  Standard](https://url.spec.whatwg.org/). `URL` is supported by modern browers
  and modern Node.js. This change is needed to address URL parsing security
  issues. If `forge.util.parseUrl()` is used directly or through `forge.xhr` or
  `forge.http` APIs, and support is needed for environments without `URL`
  support, then a polyfill must be used.
### Changed
- **BREAKING**: Increase supported Node.js version to 6.13.0 for URL support.

### Notes
- The URL related changes may expose bugs in some of the networking related
  code (unrelated to the much wider used cryptography code). The automated and
  manual test coverage for this code is weak at best. Issues or patches to
  update the code or tests would be appriciated.

    client.url.protocol.slice(0, -1) + '.' +
    client.url.hostname + '.' +
      host: client.url.hostname,
        host: client.url.hostname,
 *   socketPool: the flash socket pool to use.
  var url;
  try {
    url = new URL(options.url);
  } catch(e) {
    secure: (url.protocol === 'https:'),
      if(cn === null || client.url.hostname !== cn.value) {
      virtualHost: options.virtualHost || url.hostname,
      options.request.setField('Host', client.url.origin);
      url = new URL(url);
    // add '.' to front of URL hostname to match against domain
    var host = '.' + url.hostname;
  _clients[_client.url.origin] = _client;
    var url;
    try {
      url = new URL(options.url);
    } catch(e) {
    if(url.origin in _clients) {
      _state.client = _clients[url.origin];
      _clients[url.origin] = _state.client;
    "node": ">= 6.13.0"
  url = new URL(url);
  var client = http.createClient({
    url: url
  });