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

Commit 98b5b31

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

File tree

4 files changed

+184
-4
lines changed

4 files changed

+184
-4
lines changed

src/ng/urlUtils.js

Lines changed: 61 additions & 3 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
@@ -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

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