Skip to content

Commit

Permalink
fix($browser): normalize all optionally en/decoded characters when co…
Browse files Browse the repository at this point in the history
…mparing URLs

Fixes angular#16100
  • Loading branch information
jbedard committed Oct 16, 2018
1 parent 909176e commit 6188805
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 4 deletions.
64 changes: 61 additions & 3 deletions 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
Expand All @@ -8,7 +57,6 @@
// service.
var urlParsingNode = window.document.createElement('a');
var originUrl = urlResolve(window.location.href);
var baseUrlParsingNode;


/**
Expand Down Expand Up @@ -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) === '/')
Expand Down Expand Up @@ -178,3 +235,4 @@ function getBaseUrl() {
}
return baseUrlParsingNode.href;
}
var baseUrlParsingNode;
58 changes: 58 additions & 0 deletions test/ng/browserSpecs.js
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion test/ng/locationSpec.js
Expand Up @@ -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');
});
});
});
Expand Down
64 changes: 64 additions & 0 deletions test/ng/urlUtilsSpec.js
Expand Up @@ -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);
});
});


Expand Down

0 comments on commit 6188805

Please sign in to comment.