From f7774f608418b59a43ad1816c954654fdc8b1248 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 9 Jan 2022 07:59:52 +0100 Subject: [PATCH 01/12] [security] Fix typos in SECURITY.md --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 3a97067..f85d48f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,8 +33,8 @@ acknowledge your responsible disclosure, if you wish. ## History -> url-parse mishandles certain use a single of (back) slash such as https:\ & -> https:/ and > interprets the URI as a relative path. Browsers accept a single +> url-parse mishandles certain uses of a single (back) slash such as https:\ & +> https:/ and interprets the URI as a relative path. Browsers accept a single > backslash after the protocol, and treat it as a normal slash, while url-parse > sees it as a relative path. From 9be7ee88afd2bb04e4d5a1a8da9a389ac13f8c40 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sat, 8 Jan 2022 21:10:56 +0100 Subject: [PATCH 02/12] [fix] Correctly handle userinfo containing the at sign --- index.js | 37 ++++++++++++++++++++++------ test/test.js | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index 702308b..b819461 100644 --- a/index.js +++ b/index.js @@ -304,7 +304,11 @@ function Url(address, location, parser) { if (parse !== parse) { url[key] = address; } else if ('string' === typeof parse) { - if (~(index = address.indexOf(parse))) { + index = parse === '@' + ? address.lastIndexOf(parse) + : address.indexOf(parse); + + if (~index) { if ('number' === typeof instruction[2]) { url[key] = address.slice(0, index); address = address.slice(index + instruction[2]); @@ -370,10 +374,21 @@ function Url(address, location, parser) { // Parse down the `auth` for the username and password. // url.username = url.password = ''; + if (url.auth) { - instruction = url.auth.split(':'); - url.username = instruction[0]; - url.password = instruction[1] || ''; + index = url.auth.indexOf(':'); + + if (~index) { + url.username = url.auth.slice(0, index); + url.username = encodeURIComponent(decodeURIComponent(url.username)); + + url.password = url.auth.slice(index + 1); + url.password = encodeURIComponent(decodeURIComponent(url.password)) + } else { + url.username = encodeURIComponent(decodeURIComponent(url.auth)); + } + + url.auth = url.password ? url.username +':'+ url.password : url.username; } url.origin = url.protocol !== 'file:' && isSpecial(url.protocol) && url.host @@ -465,9 +480,17 @@ function set(part, value, fn) { break; case 'auth': - var splits = value.split(':'); - url.username = splits[0]; - url.password = splits.length === 2 ? splits[1] : ''; + var index = value.indexOf(':'); + + if (~index) { + url.username = value.slice(0, index); + url.username = encodeURIComponent(decodeURIComponent(url.username)); + + url.password = value.slice(index + 1); + url.password = encodeURIComponent(decodeURIComponent(url.password)); + } else { + url.username = encodeURIComponent(decodeURIComponent(value)); + } } for (var i = 0; i < rules.length; i++) { diff --git a/test/test.js b/test/test.js index 8130081..18f16ef 100644 --- a/test/test.js +++ b/test/test.js @@ -689,6 +689,54 @@ describe('url-parse', function () { assume(parsed.hostname).equals('www.example.com'); assume(parsed.href).equals(url); }); + + it('handles @ in username', function () { + var url = 'http://user@@www.example.com/' + , parsed = parse(url); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals('user%40'); + assume(parsed.username).equals('user%40'); + assume(parsed.password).equals(''); + assume(parsed.hostname).equals('www.example.com'); + assume(parsed.pathname).equals('/'); + assume(parsed.href).equals('http://user%40@www.example.com/'); + + url = 'http://user%40@www.example.com/'; + parsed = parse(url); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals('user%40'); + assume(parsed.username).equals('user%40'); + assume(parsed.password).equals(''); + assume(parsed.hostname).equals('www.example.com'); + assume(parsed.pathname).equals('/'); + assume(parsed.href).equals('http://user%40@www.example.com/'); + }); + + it('handles @ in password', function () { + var url = 'http://user@:pas:s@@www.example.com/' + , parsed = parse(url); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals('user%40:pas%3As%40'); + assume(parsed.username).equals('user%40'); + assume(parsed.password).equals('pas%3As%40'); + assume(parsed.hostname).equals('www.example.com'); + assume(parsed.pathname).equals('/'); + assume(parsed.href).equals('http://user%40:pas%3As%40@www.example.com/'); + + url = 'http://user%40:pas%3As%40@www.example.com/' + parsed = parse(url); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals('user%40:pas%3As%40'); + assume(parsed.username).equals('user%40'); + assume(parsed.password).equals('pas%3As%40'); + assume(parsed.hostname).equals('www.example.com'); + assume(parsed.pathname).equals('/'); + assume(parsed.href).equals('http://user%40:pas%3As%40@www.example.com/'); + }); }); it('accepts multiple ???', function () { @@ -1124,6 +1172,26 @@ describe('url-parse', function () { assume(data.username).equals(''); assume(data.password).equals('quux'); assume(data.href).equals('https://:quux@example.com/'); + + assume(data.set('auth', 'user@:pass@')).equals(data); + assume(data.username).equals('user%40'); + assume(data.password).equals('pass%40'); + assume(data.href).equals('https://user%40:pass%40@example.com/'); + + assume(data.set('auth', 'user%40:pass%40')).equals(data); + assume(data.username).equals('user%40'); + assume(data.password).equals('pass%40'); + assume(data.href).equals('https://user%40:pass%40@example.com/'); + + assume(data.set('auth', 'user:pass:word')).equals(data); + assume(data.username).equals('user'); + assume(data.password).equals('pass%3Aword'); + assume(data.href).equals('https://user:pass%3Aword@example.com/'); + + assume(data.set('auth', 'user:pass%3Aword')).equals(data); + assume(data.username).equals('user'); + assume(data.password).equals('pass%3Aword'); + assume(data.href).equals('https://user:pass%3Aword@example.com/'); }); it('updates other values', function () { From 4e53a8cad35c25e0004cee3afc1ed37ce47cad83 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Fri, 11 Feb 2022 21:15:41 +0100 Subject: [PATCH 03/12] [doc] Document that the returned hostname might be invalid --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 94f08d1..b476ed7 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ The returned `url` instance contains the following properties: - `auth`: Authentication information portion (e.g. `username:password`). - `username`: Username of basic authentication. - `password`: Password of basic authentication. -- `host`: Host name with port number. -- `hostname`: Host name without port number. +- `host`: Host name with port number. The hostname might be invalid. +- `hostname`: Host name without port number. This might be an invalid hostname. - `port`: Optional port number. - `pathname`: URL path. - `query`: Parsed object containing query string, unless parsing is set to false. From 319851bf1c294796fc73e29ff31b14d9084e4a0d Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 13 Feb 2022 08:53:54 +0100 Subject: [PATCH 04/12] [fix] Remove CR, HT, and LF Copy the behavior of browser `URL` interface and remove CR, HT, and LF from the input URL. --- index.js | 3 +++ test/test.js | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 702308b..4cd646f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ var required = require('requires-port') , qs = require('querystringify') + , CRHTLF = /[\n\r\t]/g , slashes = /^[A-Za-z][A-Za-z0-9+-.]*:\/\// , protocolre = /^([a-z][a-z0-9.+-]*:)?(\/\/)?([\\/]+)?([\S\s]*)/i , windowsDriveLetter = /^[a-zA-Z]:/ @@ -135,6 +136,7 @@ function isSpecial(scheme) { */ function extractProtocol(address, location) { address = trimLeft(address); + address = address.replace(CRHTLF, ''); location = location || {}; var match = protocolre.exec(address); @@ -235,6 +237,7 @@ function resolve(relative, base) { */ function Url(address, location, parser) { address = trimLeft(address); + address = address.replace(CRHTLF, ''); if (!(this instanceof Url)) { return new Url(address, location, parser); diff --git a/test/test.js b/test/test.js index 8130081..2799280 100644 --- a/test/test.js +++ b/test/test.js @@ -102,7 +102,7 @@ describe('url-parse', function () { }); it('does not truncate the input string', function () { - var input = 'foo\nbar\rbaz\u2028qux\u2029'; + var input = 'foo\x0bbar\x0cbaz\u2028qux\u2029'; assume(parse.extractProtocol(input)).eql({ slashes: false, @@ -113,7 +113,16 @@ describe('url-parse', function () { }); it('trimsLeft', function () { - assume(parse.extractProtocol(' javascript://foo')).eql({ + assume(parse.extractProtocol('\x0b\x0c javascript://foo')).eql({ + slashes: true, + protocol: 'javascript:', + rest: 'foo', + slashesCount: 2 + }); + }); + + it('removes CR, HT, and LF', function () { + assume(parse.extractProtocol('jav\n\rasc\nript\r:/\t/fo\no')).eql({ slashes: true, protocol: 'javascript:', rest: 'foo', @@ -408,6 +417,31 @@ describe('url-parse', function () { assume(parsed.href).equals('//example.com'); }); + it('removes CR, HT, and LF', function () { + var parsed = parse( + 'ht\ntp://a\rb:\tcd@exam\rple.com:80\t80/pat\thname?fo\no=b\rar#ba\tz' + ); + + assume(parsed.protocol).equals('http:'); + assume(parsed.username).equals('ab'); + assume(parsed.password).equals('cd'); + assume(parsed.host).equals('example.com:8080'); + assume(parsed.hostname).equals('example.com'); + assume(parsed.port).equals('8080'); + assume(parsed.pathname).equals('/pathname'); + assume(parsed.query).equals('?foo=bar'); + assume(parsed.hash).equals('#baz'); + assume(parsed.href).equals( + 'http://ab:cd@example.com:8080/pathname?foo=bar#baz' + ); + + parsed = parse('s\nip:al\rice@atl\tanta.com'); + + assume(parsed.protocol).equals('sip:'); + assume(parsed.pathname).equals('alice@atlanta.com'); + assume(parsed.href).equals('sip:alice@atlanta.com'); + }); + describe('origin', function () { it('generates an origin property', function () { var url = 'http://google.com:80/pathname' From 193b44baf3d203560735e05eedc99d8244c9e16c Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 13 Feb 2022 09:14:05 +0100 Subject: [PATCH 05/12] [minor] Simplify whitespace regex --- index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 4cd646f..02c86e7 100644 --- a/index.js +++ b/index.js @@ -6,8 +6,7 @@ var required = require('requires-port') , slashes = /^[A-Za-z][A-Za-z0-9+-.]*:\/\// , protocolre = /^([a-z][a-z0-9.+-]*:)?(\/\/)?([\\/]+)?([\S\s]*)/i , windowsDriveLetter = /^[a-zA-Z]:/ - , whitespace = '[\\x09\\x0A\\x0B\\x0C\\x0D\\x20\\xA0\\u1680\\u180E\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200A\\u202F\\u205F\\u3000\\u2028\\u2029\\uFEFF]' - , left = new RegExp('^'+ whitespace +'+'); + , whitespace = /^[ \f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/; /** * Trim a given string. @@ -16,7 +15,7 @@ var required = require('requires-port') * @public */ function trimLeft(str) { - return (str ? str : '').toString().replace(left, ''); + return (str ? str : '').toString().replace(whitespace, ''); } /** From e4a5807d95b971577e4d888f5b99d64a40851386 Mon Sep 17 00:00:00 2001 From: Martijn Swaagman Date: Sun, 13 Feb 2022 11:37:14 +0100 Subject: [PATCH 06/12] 1.5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b777acb..f079c10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "url-parse", - "version": "1.5.4", + "version": "1.5.5", "description": "Small footprint URL parser that works seamlessly across Node.js and browser environments", "main": "index.js", "scripts": { From 4c9fa234c01dca52698666378360ad2fdfb05470 Mon Sep 17 00:00:00 2001 From: Martijn Swaagman Date: Sun, 13 Feb 2022 16:25:42 +0100 Subject: [PATCH 07/12] 1.5.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f079c10..cf472f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "url-parse", - "version": "1.5.5", + "version": "1.5.6", "description": "Small footprint URL parser that works seamlessly across Node.js and browser environments", "main": "index.js", "scripts": { From e6fa43422c52f34c73146552ec9916125dc59525 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 13 Feb 2022 20:21:27 +0100 Subject: [PATCH 08/12] [security] Add credits for incorrect handling of userinfo vulnerability --- SECURITY.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index f85d48f..f3e7892 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,14 @@ acknowledge your responsible disclosure, if you wish. ## History +> Incorrect handling of username and password can lead to authorization bypass. + +- **Reporter credits** + - ranjit-git + - GitHub: [@ranjit-git](https://github.com/ranjit-git) +- Huntr report: https://www.huntr.dev/bounties/6d1bc51f-1876-4f5b-a2c2-734e09e8e05b/ +- Fixed in: 1.5.6 + > url-parse mishandles certain uses of a single (back) slash such as https:\ & > https:/ and interprets the URI as a relative path. Browsers accept a single > backslash after the protocol, and treat it as a normal slash, while url-parse From 78e9f2f41285d83e7d91706be5bd439656fe3bc3 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Sun, 13 Feb 2022 20:40:49 +0100 Subject: [PATCH 09/12] [security] Fix nits --- SECURITY.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index f3e7892..af05717 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,14 +41,16 @@ acknowledge your responsible disclosure, if you wish. - Huntr report: https://www.huntr.dev/bounties/6d1bc51f-1876-4f5b-a2c2-734e09e8e05b/ - Fixed in: 1.5.6 +--- + > url-parse mishandles certain uses of a single (back) slash such as https:\ & > https:/ and interprets the URI as a relative path. Browsers accept a single > backslash after the protocol, and treat it as a normal slash, while url-parse > sees it as a relative path. - **Reporter credits** - - Ready-Research - - GitHub: [@Ready-Reserach](https://github.com/ready-research) + - ready-research + - GitHub: [@ready-research](https://github.com/ready-research) - Huntr report: https://www.huntr.dev/bounties/1625557993985-unshiftio/url-parse/ - Fixed in: 1.5.2 From 88df2346855f70cec9713b362ca32a4691dc271a Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 16 Feb 2022 19:00:41 +0100 Subject: [PATCH 10/12] [doc] Add soft deprecation notice --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index b476ed7..e5bf8d7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ [![Sauce Test Status](https://saucelabs.com/browser-matrix/url-parse.svg)](https://saucelabs.com/u/url-parse) +**`url-parse` was created in 2014 when the WHATWG URL API was not available in +Node.js and the `URL` interface was supported only in some browsers. Today this +is no longer true. The `URL` interface is available in all supported Node.js +release lines and basically all browsers. Consider using it for better security +and accuracy.** + The `url-parse` method exposes two different API interfaces. The [`url`](https://nodejs.org/api/url.html) interface that you know from Node.js and the new [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) From ef45a1355375a8244063793a19059b4f62fc8788 Mon Sep 17 00:00:00 2001 From: Luigi Pinca Date: Wed, 16 Feb 2022 19:05:36 +0100 Subject: [PATCH 11/12] [fix] Readd the empty userinfo to `url.href` (#226) If the userinfo is present but empty, the parsed host is also empty, and `url.pathname` is not `'/'`, then readd the empty userinfo to `url.href`, otherwise the original invalid URL might be transformed into a valid one with `url.pathname` as host. --- index.js | 11 ++++++++++ test/test.js | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/index.js b/index.js index 517b6b6..d808b13 100644 --- a/index.js +++ b/index.js @@ -539,6 +539,17 @@ function toString(stringify) { } else if (url.password) { result += ':'+ url.password; result += '@'; + } else if ( + url.protocol !== 'file:' && + isSpecial(url.protocol) && + !url.host && + url.pathname !== '/' + ) { + // + // Add back the empty userinfo, otherwise the original invalid URL + // might be transformed into a valid one with `url.pathname` as host. + // + result += '@'; } result += url.host + url.pathname; diff --git a/test/test.js b/test/test.js index 6d53eb9..98880b3 100644 --- a/test/test.js +++ b/test/test.js @@ -771,6 +771,65 @@ describe('url-parse', function () { assume(parsed.pathname).equals('/'); assume(parsed.href).equals('http://user%40:pas%3As%40@www.example.com/'); }); + + it('adds @ to href if auth and host are empty', function () { + var parsed, i = 0; + var urls = [ + 'http:@/127.0.0.1', + 'http::@/127.0.0.1', + 'http:/@/127.0.0.1', + 'http:/:@/127.0.0.1', + 'http://@/127.0.0.1', + 'http://:@/127.0.0.1', + 'http:///@/127.0.0.1', + 'http:///:@/127.0.0.1' + ]; + + for (; i < urls.length; i++) { + parsed = parse(urls[i]); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals(''); + assume(parsed.username).equals(''); + assume(parsed.password).equals(''); + assume(parsed.host).equals(''); + assume(parsed.hostname).equals(''); + assume(parsed.pathname).equals('/127.0.0.1'); + assume(parsed.origin).equals('null'); + assume(parsed.href).equals('http://@/127.0.0.1'); + assume(parsed.toString()).equals('http://@/127.0.0.1'); + } + + urls = [ + 'http:@/', + 'http:@', + 'http::@/', + 'http::@', + 'http:/@/', + 'http:/@', + 'http:/:@/', + 'http:/:@', + 'http://@/', + 'http://@', + 'http://:@/', + 'http://:@' + ]; + + for (i = 0; i < urls.length; i++) { + parsed = parse(urls[i]); + + assume(parsed.protocol).equals('http:'); + assume(parsed.auth).equals(''); + assume(parsed.username).equals(''); + assume(parsed.password).equals(''); + assume(parsed.host).equals(''); + assume(parsed.hostname).equals(''); + assume(parsed.pathname).equals('/'); + assume(parsed.origin).equals('null'); + assume(parsed.href).equals('http:///'); + assume(parsed.toString()).equals('http:///'); + } + }); }); it('accepts multiple ???', function () { From 8b3f5f2c88a4cfc2880f2319c307994cb25bb10a Mon Sep 17 00:00:00 2001 From: Martijn Swaagman Date: Wed, 16 Feb 2022 21:02:02 +0100 Subject: [PATCH 12/12] 1.5.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf472f5..7bddf4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "url-parse", - "version": "1.5.6", + "version": "1.5.7", "description": "Small footprint URL parser that works seamlessly across Node.js and browser environments", "main": "index.js", "scripts": {