Skip to content

Commit 5d33cee

Browse files
committed
fix($browser): normalize all optionally en/decoded characters when comparing URLs
Fixes angular#16100
1 parent 98e884c commit 5d33cee

File tree

4 files changed

+186
-1
lines changed

4 files changed

+186
-1
lines changed

src/ng/urlUtils.js

+64
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#appendix-A
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
@@ -84,6 +133,21 @@ function urlResolve(url) {
84133
hostname = '[' + hostname + ']';
85134
}
86135

136+
// Support: everything
137+
//
138+
// No browser normalizes all of the optionally encoded characters consistently.
139+
// Various browsers normalize a subsets of the unreserved characters within the
140+
// path, search and hash portions of the URL.
141+
if (urlParsingNode.pathname) {
142+
urlParsingNode.pathname = normalizeUriPath(urlParsingNode.pathname);
143+
}
144+
if (urlParsingNode.search) {
145+
urlParsingNode.search = normalizeUriQuery(urlParsingNode.search.replace(/^\?/, ''));
146+
}
147+
if (urlParsingNode.hash) {
148+
urlParsingNode.hash = normalizeUriFragment(urlParsingNode.hash.replace(/^#/, ''));
149+
}
150+
87151
return {
88152
href: urlParsingNode.href,
89153
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',

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

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

49112

0 commit comments

Comments
 (0)