Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 6188805

Browse files
committed
fix($browser): normalize all optionally en/decoded characters when comparing URLs
Fixes #16100
1 parent 909176e commit 6188805

File tree

4 files changed

+184
-4
lines changed

4 files changed

+184
-4
lines changed

src/ng/urlUtils.js

+61-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,53 @@
11
'use strict';
2+
3+
// ABNF info for non-encoded characters of path entries, query and fragment
4+
// https://tools.ietf.org/html/rfc3986#section-6
5+
var sub_delims = '!$&\'()*+,;=';
6+
var alpha = 'abcdefghijklmnopqrstuvwxyz';
7+
var digit = '0123456789'
8+
var unreserved = alpha + digit + '-._~';
9+
var pchar = unreserved + sub_delims + ':' + '@'; //pct-encoded excluded
10+
var query = (pchar + '/' + '?').replace(/[&=]/g, ''); //&= excluded
11+
var fragment = pchar + '/' + '?';
12+
13+
// Map of the encoded version of all characters not requiring encoding
14+
var PATH_NON_ENCODED = charsToEncodedMap(pchar);
15+
var QUERY_NON_ENCODED = charsToEncodedMap(query);
16+
var FRAGMENT_NON_ENCODED = charsToEncodedMap(fragment);
17+
18+
// Util for generating a map of %XX (in upper case) to the represented character
19+
function charsToEncodedMap(chars) {
20+
return chars.split('').reduce(function(o, c) {
21+
o[ '%' + c.charCodeAt(0).toString(16).toUpperCase() ] = c;
22+
return o;
23+
}, {});
24+
}
25+
26+
function decodeUnnecesary(s, nonEncoded) {
27+
return s.replace(/%[0-9][0-9a-f]/gi, function(c) {
28+
// Uppercase and lowercase hexadecimal digits are equivelent, but RFC3986 specifies
29+
// "For consistency, URI producers and normalizers should use uppercase hexadecimal
30+
// digits for all percent-encodings"
31+
c = uppercase(c);
32+
33+
return nonEncoded[c] || c;
34+
});
35+
}
36+
37+
function normalizeUriPathSegment(pct_encoded) {
38+
return decodeUnnecesary(pct_encoded, PATH_NON_ENCODED);
39+
}
40+
function normalizeUriPath(path) {
41+
return path.split('/').map(normalizeUriPathSegment).join('/');
42+
}
43+
function normalizeUriQuery(query) {
44+
return decodeUnnecesary(query, QUERY_NON_ENCODED);
45+
}
46+
function normalizeUriFragment(fragment) {
47+
return decodeUnnecesary(fragment, FRAGMENT_NON_ENCODED);
48+
}
49+
50+
251
// NOTE: The usage of window and document instead of $window and $document here is
352
// deliberate. This service depends on the specific behavior of anchor nodes created by the
453
// browser (resolving and parsing URLs) that is unlikely to be provided by mock objects and
@@ -8,7 +57,6 @@
857
// service.
958
var urlParsingNode = window.document.createElement('a');
1059
var originUrl = urlResolve(window.location.href);
11-
var baseUrlParsingNode;
1260

1361

1462
/**
@@ -72,12 +120,21 @@ function urlResolve(url) {
72120

73121
urlParsingNode.setAttribute('href', href);
74122

123+
// Support: everything
124+
//
125+
// No browser normalizes all of the optionally encoded characters consistently.
126+
// Various browsers normalize a subsets of the unreserved characters within the
127+
// path, search and hash portions of the URL.
128+
urlParsingNode.pathname = normalizeUriPath(urlParsingNode.pathname);
129+
urlParsingNode.search = normalizeUriQuery(urlParsingNode.search.replace(/^\?/, ''));
130+
urlParsingNode.hash = normalizeUriFragment(urlParsingNode.hash.replace(/^\#/, ''));
131+
75132
return {
76133
href: urlParsingNode.href,
77134
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
78135
host: urlParsingNode.host,
79-
search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '',
80-
hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '',
136+
search: urlParsingNode.search.replace(/^\?/, ''),
137+
hash: urlParsingNode.hash.replace(/^#/, ''),
81138
hostname: urlParsingNode.hostname,
82139
port: urlParsingNode.port,
83140
pathname: (urlParsingNode.pathname.charAt(0) === '/')
@@ -178,3 +235,4 @@ function getBaseUrl() {
178235
}
179236
return baseUrlParsingNode.href;
180237
}
238+
var baseUrlParsingNode;

test/ng/browserSpecs.js

+58
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,64 @@ describe('browser', function() {
690690
expect(locationReplace).not.toHaveBeenCalled();
691691
});
692692

693+
it('should not detect changes on $$checkUrlChange() due to input vs actual encoding', function() {
694+
var callback = jasmine.createSpy('onUrlChange');
695+
browser.onUrlChange(callback);
696+
697+
browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@');
698+
browser.$$checkUrlChange();
699+
expect(callback).not.toHaveBeenCalled();
700+
701+
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');
702+
browser.$$checkUrlChange();
703+
expect(callback).not.toHaveBeenCalled();
704+
});
705+
706+
it('should not do pushState with a URL only different in encoding (less)', function() {
707+
// A URL from something such as window.location.href
708+
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');
709+
710+
pushState.calls.reset();
711+
replaceState.calls.reset();
712+
locationReplace.calls.reset();
713+
714+
// A prettier URL from something such as $location
715+
browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@');
716+
expect(pushState).not.toHaveBeenCalled();
717+
expect(replaceState).not.toHaveBeenCalled();
718+
expect(locationReplace).not.toHaveBeenCalled();
719+
});
720+
721+
it('should not do pushState with a URL only different in encoding (more)', function() {
722+
// A prettier URL from something such as $location
723+
browser.url('http://server/-._~!$&\'()*+,;=:@/abc?q=-._~!$\'()*+,;:@/?"#-._~!$&\'()*+,;=:@');
724+
725+
pushState.calls.reset();
726+
replaceState.calls.reset();
727+
locationReplace.calls.reset();
728+
729+
// A URL from something such as window.location.href
730+
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');
731+
expect(pushState).not.toHaveBeenCalled();
732+
expect(replaceState).not.toHaveBeenCalled();
733+
expect(locationReplace).not.toHaveBeenCalled();
734+
});
735+
736+
it('should not do pushState with a URL only different in encoding case', function() {
737+
// A prettier URL from something such as $location
738+
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');
739+
740+
pushState.calls.reset();
741+
replaceState.calls.reset();
742+
locationReplace.calls.reset();
743+
744+
// A URL from something such as window.location.href
745+
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');
746+
expect(pushState).not.toHaveBeenCalled();
747+
expect(replaceState).not.toHaveBeenCalled();
748+
expect(locationReplace).not.toHaveBeenCalled();
749+
});
750+
693751
it('should not do pushState with a URL only adding a trailing slash after domain', function() {
694752
// A domain without a trailing /
695753
browser.url('http://server');

test/ng/locationSpec.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1180,7 +1180,7 @@ describe('$location', function() {
11801180
$location.hash('test');
11811181

11821182
$rootScope.$digest();
1183-
expect($browser.url()).toBe('http://new.com/a/b##test');
1183+
expect($browser.url()).toBe('http://new.com/a/b#test');
11841184
});
11851185
});
11861186
});

test/ng/urlUtilsSpec.js

+64
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,70 @@ describe('urlUtils', function() {
3131
var parsed = urlResolve('/');
3232
expect(parsed.pathname).toBe('/');
3333
});
34+
35+
36+
it('should normalize trailing slashes on host', function() {
37+
var slashed = urlResolve('http://foo.bar/');
38+
var noSlash = urlResolve('http://foo.bar');
39+
40+
expect(slashed).toEqual(noSlash);
41+
});
42+
43+
it('should normalize empty search', function() {
44+
var fromSearched = urlResolve('http://foo.bar?');
45+
var fromNoSearch = urlResolve('http://foo.bar');
46+
47+
expect(fromSearched).toEqual(fromNoSearch);
48+
});
49+
50+
it('should normalize empty hash', function() {
51+
var fromHashed = urlResolve('http://foo.bar#');
52+
var fromNoHash = urlResolve('http://foo.bar');
53+
54+
expect(fromHashed).toEqual(fromNoHash);
55+
});
56+
57+
it('should normalize encoding of optionally-encoded characters in pathname', function() {
58+
var fromEncoded = urlResolve('/-._~!$&\'()*+,;=:@/-._~!$&\'()*+,;=:@');
59+
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');
60+
61+
expect(fromEncoded).toEqual(fromDecoded);
62+
});
63+
64+
it('should normalize encoding of optionally-encoded characters in search', function() {
65+
var fromEncoded = urlResolve('/asdf?foo=-._~!$\'()*+,;:@/?"&bar=-._~!$\'()*+,;:@/?"');
66+
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');
67+
68+
expect(fromEncoded).toEqual(fromDecoded);
69+
});
70+
71+
it('should normalize encoding of optionally-encoded characters in hash', function() {
72+
var fromEncoded = urlResolve('/asdf#-._~!$&\'()*+,;=:@');
73+
var fromDecoded = urlResolve('/asdf#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40');
74+
75+
expect(fromEncoded).toEqual(fromDecoded);
76+
});
77+
78+
it('should normalize casing of encoded characters in pathname', function() {
79+
var fromUpperHex = urlResolve('/asdf/%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40/');
80+
var fromLowerHex = urlResolve('/asdf/%2d%2e%5f%7e%21%24%26%27%28%29%2a%2b%2c%3b%3d%3a%40/');
81+
82+
expect(fromUpperHex).toEqual(fromLowerHex);
83+
});
84+
85+
it('should normalize casing of encoded characters in search', function() {
86+
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');
87+
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');
88+
89+
expect(fromUpperHex).toEqual(fromLowerHex);
90+
});
91+
92+
it('should normalize casing of encoded characters in hash', function() {
93+
var fromUpperHex = urlResolve('/asdf#%2D%2E%5F%7E%21%24%26%27%28%29%2A%2B%2C%3B%3D%3A%40');
94+
var fromLowerHex = urlResolve('/asdf#%2d%2e%5f%7e%21%24%26%27%28%29%2a%2b%2c%3b%3d%3a%40');
95+
96+
expect(fromUpperHex).toEqual(fromLowerHex);
97+
});
3498
});
3599

36100

0 commit comments

Comments
 (0)