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

Commit c8b94c8

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

File tree

4 files changed

+183
-3
lines changed

4 files changed

+183
-3
lines changed

src/ng/urlUtils.js

Lines changed: 60 additions & 2 deletions
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
@@ -72,12 +121,21 @@ function urlResolve(url) {
72121

73122
urlParsingNode.setAttribute('href', href);
74123

124+
// Support: everything
125+
//
126+
// No browser normalizes all of the optionally encoded characters consistently.
127+
// Various browsers normalize a subsets of the unreserved characters within the
128+
// path, search and hash portions of the URL.
129+
urlParsingNode.pathname = normalizeUriPath(urlParsingNode.pathname);
130+
urlParsingNode.search = normalizeUriQuery(urlParsingNode.search.replace(/^\?/, ''));
131+
urlParsingNode.hash = normalizeUriFragment(urlParsingNode.hash.replace(/^\#/, ''));
132+
75133
return {
76134
href: urlParsingNode.href,
77135
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
78136
host: urlParsingNode.host,
79-
search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '',
80-
hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '',
137+
search: urlParsingNode.search.replace(/^\?/, ''),
138+
hash: urlParsingNode.hash.replace(/^#/, ''),
81139
hostname: urlParsingNode.hostname,
82140
port: urlParsingNode.port,
83141
pathname: (urlParsingNode.pathname.charAt(0) === '/')

test/ng/browserSpecs.js

Lines changed: 58 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 64 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy