From 6188805c042fa38b91cf59d79614c48adf58db30 Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Sat, 13 Oct 2018 20:56:07 -0700 Subject: [PATCH] fix($browser): normalize all optionally en/decoded characters when comparing URLs Fixes #16100 --- src/ng/urlUtils.js | 64 +++++++++++++++++++++++++++++++++++++++-- test/ng/browserSpecs.js | 58 +++++++++++++++++++++++++++++++++++++ test/ng/locationSpec.js | 2 +- test/ng/urlUtilsSpec.js | 64 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 4 deletions(-) diff --git a/src/ng/urlUtils.js b/src/ng/urlUtils.js index 149e14c707b3..07f2ed985eef 100644 --- a/src/ng/urlUtils.js +++ b/src/ng/urlUtils.js @@ -1,4 +1,53 @@ 'use strict'; + +// ABNF info for non-encoded characters of path entries, query and fragment +// https://tools.ietf.org/html/rfc3986#section-6 +var sub_delims = '!$&\'()*+,;='; +var alpha = 'abcdefghijklmnopqrstuvwxyz'; +var digit = '0123456789' +var unreserved = alpha + digit + '-._~'; +var pchar = unreserved + sub_delims + ':' + '@'; //pct-encoded excluded +var query = (pchar + '/' + '?').replace(/[&=]/g, ''); //&= excluded +var fragment = pchar + '/' + '?'; + +// Map of the encoded version of all characters not requiring encoding +var PATH_NON_ENCODED = charsToEncodedMap(pchar); +var QUERY_NON_ENCODED = charsToEncodedMap(query); +var FRAGMENT_NON_ENCODED = charsToEncodedMap(fragment); + +// Util for generating a map of %XX (in upper case) to the represented character +function charsToEncodedMap(chars) { + return chars.split('').reduce(function(o, c) { + o[ '%' + c.charCodeAt(0).toString(16).toUpperCase() ] = c; + return o; + }, {}); +} + +function decodeUnnecesary(s, nonEncoded) { + return s.replace(/%[0-9][0-9a-f]/gi, function(c) { + // Uppercase and lowercase hexadecimal digits are equivelent, but RFC3986 specifies + // "For consistency, URI producers and normalizers should use uppercase hexadecimal + // digits for all percent-encodings" + c = uppercase(c); + + return nonEncoded[c] || c; + }); +} + +function normalizeUriPathSegment(pct_encoded) { + return decodeUnnecesary(pct_encoded, PATH_NON_ENCODED); +} +function normalizeUriPath(path) { + return path.split('/').map(normalizeUriPathSegment).join('/'); +} +function normalizeUriQuery(query) { + return decodeUnnecesary(query, QUERY_NON_ENCODED); +} +function normalizeUriFragment(fragment) { + return decodeUnnecesary(fragment, FRAGMENT_NON_ENCODED); +} + + // NOTE: The usage of window and document instead of $window and $document here is // deliberate. This service depends on the specific behavior of anchor nodes created by the // browser (resolving and parsing URLs) that is unlikely to be provided by mock objects and @@ -8,7 +57,6 @@ // service. var urlParsingNode = window.document.createElement('a'); var originUrl = urlResolve(window.location.href); -var baseUrlParsingNode; /** @@ -72,12 +120,21 @@ function urlResolve(url) { urlParsingNode.setAttribute('href', href); + // Support: everything + // + // No browser normalizes all of the optionally encoded characters consistently. + // Various browsers normalize a subsets of the unreserved characters within the + // path, search and hash portions of the URL. + urlParsingNode.pathname = normalizeUriPath(urlParsingNode.pathname); + urlParsingNode.search = normalizeUriQuery(urlParsingNode.search.replace(/^\?/, '')); + urlParsingNode.hash = normalizeUriFragment(urlParsingNode.hash.replace(/^\#/, '')); + return { href: urlParsingNode.href, protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '', host: urlParsingNode.host, - search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '', - hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '', + search: urlParsingNode.search.replace(/^\?/, ''), + hash: urlParsingNode.hash.replace(/^#/, ''), hostname: urlParsingNode.hostname, port: urlParsingNode.port, pathname: (urlParsingNode.pathname.charAt(0) === '/') @@ -178,3 +235,4 @@ function getBaseUrl() { } return baseUrlParsingNode.href; } +var baseUrlParsingNode; diff --git a/test/ng/browserSpecs.js b/test/ng/browserSpecs.js index a32e6c0a15d3..7ffb528c8b9a 100644 --- a/test/ng/browserSpecs.js +++ b/test/ng/browserSpecs.js @@ -690,6 +690,64 @@ describe('browser', function() { expect(locationReplace).not.toHaveBeenCalled(); }); + it('should not detect changes on $$checkUrlChange() due to input vs actual encoding', function() { + var callback = jasmine.createSpy('onUrlChange'); + browser.onUrlChange(callback); + + browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@'); + browser.$$checkUrlChange(); + expect(callback).not.toHaveBeenCalled(); + + browser.url('http://server/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/abc?q=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + browser.$$checkUrlChange(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not do pushState with a URL only different in encoding (less)', function() { + // A URL from something such as window.location.href + browser.url('http://server/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/abc?q=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + + pushState.calls.reset(); + replaceState.calls.reset(); + locationReplace.calls.reset(); + + // A prettier URL from something such as $location + browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@'); + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should not do pushState with a URL only different in encoding (more)', function() { + // A prettier URL from something such as $location + browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@'); + + pushState.calls.reset(); + replaceState.calls.reset(); + locationReplace.calls.reset(); + + // A URL from something such as window.location.href + browser.url('http://server/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/abc?q=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); + + it('should not do pushState with a URL only different in encoding case', function() { + // A prettier URL from something such as $location + browser.url('http://server/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/abc?q=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22'); + + pushState.calls.reset(); + replaceState.calls.reset(); + locationReplace.calls.reset(); + + // A URL from something such as window.location.href + browser.url('http://server/%2d%2e%5f%7e%21%24%26%27%28%29%2a%2b%2c%3b%3d%3a%40/abc?q=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22'); + expect(pushState).not.toHaveBeenCalled(); + expect(replaceState).not.toHaveBeenCalled(); + expect(locationReplace).not.toHaveBeenCalled(); + }); + it('should not do pushState with a URL only adding a trailing slash after domain', function() { // A domain without a trailing / browser.url('http://server'); diff --git a/test/ng/locationSpec.js b/test/ng/locationSpec.js index e5719ae84dc0..517ba8f10e66 100644 --- a/test/ng/locationSpec.js +++ b/test/ng/locationSpec.js @@ -1180,7 +1180,7 @@ describe('$location', function() { $location.hash('test'); $rootScope.$digest(); - expect($browser.url()).toBe('http://new.com/a/b##test'); + expect($browser.url()).toBe('http://new.com/a/b#test'); }); }); }); diff --git a/test/ng/urlUtilsSpec.js b/test/ng/urlUtilsSpec.js index ebd864076623..8c213376e5e3 100644 --- a/test/ng/urlUtilsSpec.js +++ b/test/ng/urlUtilsSpec.js @@ -31,6 +31,70 @@ describe('urlUtils', function() { var parsed = urlResolve('/'); expect(parsed.pathname).toBe('/'); }); + + + it('should normalize trailing slashes on host', function() { + var slashed = urlResolve('http://foo.bar/'); + var noSlash = urlResolve('http://foo.bar'); + + expect(slashed).toEqual(noSlash); + }); + + it('should normalize empty search', function() { + var fromSearched = urlResolve('http://foo.bar?'); + var fromNoSearch = urlResolve('http://foo.bar'); + + expect(fromSearched).toEqual(fromNoSearch); + }); + + it('should normalize empty hash', function() { + var fromHashed = urlResolve('http://foo.bar#'); + var fromNoHash = urlResolve('http://foo.bar'); + + expect(fromHashed).toEqual(fromNoHash); + }); + + it('should normalize encoding of optionally-encoded characters in pathname', function() { + var fromEncoded = urlResolve('/-._~!$&\'()*+,;=:@/-._~!$&\'()*+,;=:@'); + var fromDecoded = urlResolve('/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + + expect(fromEncoded).toEqual(fromDecoded); + }); + + it('should normalize encoding of optionally-encoded characters in search', function() { + var fromEncoded = urlResolve('/asdf?foo=-._~!$\'()*+,;:@/?"&bar=-._~!$\'()*+,;:@/?"'); + var fromDecoded = urlResolve('/asdf?foo=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22&bar=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22'); + + expect(fromEncoded).toEqual(fromDecoded); + }); + + it('should normalize encoding of optionally-encoded characters in hash', function() { + var fromEncoded = urlResolve('/asdf#-._~!$&\'()*+,;=:@'); + var fromDecoded = urlResolve('/asdf#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + + expect(fromEncoded).toEqual(fromDecoded); + }); + + it('should normalize casing of encoded characters in pathname', function() { + var fromUpperHex = urlResolve('/asdf/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/'); + var fromLowerHex = urlResolve('/asdf/%2d%2e%5f%7e%21%24%26%27%28%29%2a%2b%2c%3b%3d%3a%40/'); + + expect(fromUpperHex).toEqual(fromLowerHex); + }); + + it('should normalize casing of encoded characters in search', function() { + var fromUpperHex = urlResolve('/asdf?foo=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22&bar=%2D%2E%5F%7E%21%24%27%28%29%2A%2B%2C%3B%3A%40%2F%3F%22'); + var fromLowerHex = urlResolve('/asdf?foo=%2d%2e%5f%7e%21%24%27%28%29%2a%2b%2c%3b%3a%40%2f%3f%22&bar=%2d%2e%5f%7e%21%24%27%28%29%2a%2b%2c%3b%3a%40%2f%3f%22'); + + expect(fromUpperHex).toEqual(fromLowerHex); + }); + + it('should normalize casing of encoded characters in hash', function() { + var fromUpperHex = urlResolve('/asdf#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40'); + var fromLowerHex = urlResolve('/asdf#%2d%2e%5f%7e%21%24%26%27%28%29%2a%2b%2c%3b%3d%3a%40'); + + expect(fromUpperHex).toEqual(fromLowerHex); + }); });