Skip to content

Commit 789a47e

Browse files
jasonadenalexeagle
authored andcommitted
fix(router): fix URL serialization so special characters are only encoded where needed (#22337)
This change brings Angular largely in line with how AngularJS previously serialized URLs. This is based on RFC 3986 and resolves issues such as the above #10280 where URLs could be parsed, re-serialized, then parsed again producing a different result on the second parsing. Adjustments to be aware of in this commit: * URI fragments will now serialize the same as query strings * In the URI path or segments (portion prior to query string and/or fragment), the plus sign (`+`) and ampersand (`&`) will appear decoded * In the URL path or segments, parentheses values (`(` and `)`) will now appear percent encoded as `%28` and `%29` respectively * In the URL path or segments, semicolons will be encoded in their percent encoding `%3B` NOTE: Parentheses and semicolons denoting auxillary routes or matrix params will still appear in their decoded form -- only parentheses and semicolons used as values in a segment or key/value pair for matrix params will be encoded. While these changes are not considered breaking because applications should be decoding URLs and key/value pairs, it is possible that some unit tests will break if comparing hard-coded URLs in tests since that hard coded string will represent the old encoding. Therefore we are releasing this fix in the upcoming Angular v6 rather than adding it to a patch for v5. Fixes: #10280 PR Close #22337
1 parent 984a13e commit 789a47e

File tree

2 files changed

+174
-27
lines changed

2 files changed

+174
-27
lines changed

packages/router/src/url_tree.ts

+45-15
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export class DefaultUrlSerializer implements UrlSerializer {
280280
serialize(tree: UrlTree): string {
281281
const segment = `/${serializeSegment(tree.root, true)}`;
282282
const query = serializeQueryParams(tree.queryParams);
283-
const fragment = typeof tree.fragment === `string` ? `#${encodeURI(tree.fragment !)}` : '';
283+
const fragment = typeof tree.fragment === `string` ? `#${encodeUriQuery(tree.fragment !)}` : '';
284284

285285
return `${segment}${query}${fragment}`;
286286
}
@@ -326,42 +326,72 @@ function serializeSegment(segment: UrlSegmentGroup, root: boolean): string {
326326
}
327327

328328
/**
329-
* This method is intended for encoding *key* or *value* parts of query component. We need a custom
330-
* method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be
331-
* encoded per http://tools.ietf.org/html/rfc3986:
329+
* Encodes a URI string with the default encoding. This function will only ever be called from
330+
* `encodeUriQuery` or `encodeUriSegment` as it's the base set of encodings to be used. We need
331+
* a custom encoding because encodeURIComponent is too aggressive and encodes stuff that doesn't
332+
* have to be encoded per http://tools.ietf.org/html/rfc3986:
332333
* query = *( pchar / "/" / "?" )
333334
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
334335
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
335336
* pct-encoded = "%" HEXDIG HEXDIG
336337
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
337338
* / "*" / "+" / "," / ";" / "="
338339
*/
339-
export function encode(s: string): string {
340+
function encodeUriString(s: string): string {
340341
return encodeURIComponent(s)
341342
.replace(/%40/g, '@')
342343
.replace(/%3A/gi, ':')
343344
.replace(/%24/g, '$')
344-
.replace(/%2C/gi, ',')
345-
.replace(/%3B/gi, ';');
345+
.replace(/%2C/gi, ',');
346+
}
347+
348+
/**
349+
* This function should be used to encode both keys and values in a query string key/value or the
350+
* URL fragment. In the following URL, you need to call encodeUriQuery on "k", "v" and "f":
351+
*
352+
* http://www.site.org/html;mk=mv?k=v#f
353+
*/
354+
export function encodeUriQuery(s: string): string {
355+
return encodeUriString(s).replace(/%3B/gi, ';');
356+
}
357+
358+
/**
359+
* This function should be run on any URI segment as well as the key and value in a key/value
360+
* pair for matrix params. In the following URL, you need to call encodeUriSegment on "html",
361+
* "mk", and "mv":
362+
*
363+
* http://www.site.org/html;mk=mv?k=v#f
364+
*/
365+
export function encodeUriSegment(s: string): string {
366+
return encodeUriString(s).replace(/\(/g, '%28').replace(/\)/g, '%29').replace(/%26/gi, '&');
346367
}
347368

348369
export function decode(s: string): string {
349370
return decodeURIComponent(s);
350371
}
351372

373+
// Query keys/values should have the "+" replaced first, as "+" in a query string is " ".
374+
// decodeURIComponent function will not decode "+" as a space.
375+
export function decodeQuery(s: string): string {
376+
return decode(s.replace(/\+/g, '%20'));
377+
}
378+
352379
export function serializePath(path: UrlSegment): string {
353-
return `${encode(path.path)}${serializeParams(path.parameters)}`;
380+
return `${encodeUriSegment(path.path)}${serializeMatrixParams(path.parameters)}`;
354381
}
355382

356-
function serializeParams(params: {[key: string]: string}): string {
357-
return Object.keys(params).map(key => `;${encode(key)}=${encode(params[key])}`).join('');
383+
function serializeMatrixParams(params: {[key: string]: string}): string {
384+
return Object.keys(params)
385+
.map(key => `;${encodeUriSegment(key)}=${encodeUriSegment(params[key])}`)
386+
.join('');
358387
}
359388

360389
function serializeQueryParams(params: {[key: string]: any}): string {
361390
const strParams: string[] = Object.keys(params).map((name) => {
362391
const value = params[name];
363-
return Array.isArray(value) ? value.map(v => `${encode(name)}=${encode(v)}`).join('&') :
364-
`${encode(name)}=${encode(value)}`;
392+
return Array.isArray(value) ?
393+
value.map(v => `${encodeUriQuery(name)}=${encodeUriQuery(v)}`).join('&') :
394+
`${encodeUriQuery(name)}=${encodeUriQuery(value)}`;
365395
});
366396

367397
return strParams.length ? `?${strParams.join("&")}` : '';
@@ -414,7 +444,7 @@ class UrlParser {
414444
}
415445

416446
parseFragment(): string|null {
417-
return this.consumeOptional('#') ? decodeURI(this.remaining) : null;
447+
return this.consumeOptional('#') ? decodeURIComponent(this.remaining) : null;
418448
}
419449

420450
private parseChildren(): {[outlet: string]: UrlSegmentGroup} {
@@ -506,8 +536,8 @@ class UrlParser {
506536
}
507537
}
508538

509-
const decodedKey = decode(key);
510-
const decodedVal = decode(value);
539+
const decodedKey = decodeQuery(key);
540+
const decodedVal = decodeQuery(value);
511541

512542
if (params.hasOwnProperty(decodedKey)) {
513543
// Append to existing values

packages/router/test/url_serializer.spec.ts

+129-12
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {PRIMARY_OUTLET} from '../src/shared';
10-
import {DefaultUrlSerializer, UrlSegmentGroup, encode, serializePath} from '../src/url_tree';
10+
import {DefaultUrlSerializer, UrlSegmentGroup, encodeUriQuery, encodeUriSegment, serializePath} from '../src/url_tree';
1111

1212
describe('url serializer', () => {
1313
const url = new DefaultUrlSerializer();
@@ -189,7 +189,7 @@ describe('url serializer', () => {
189189
describe('encoding/decoding', () => {
190190
it('should encode/decode path segments and parameters', () => {
191191
const u =
192-
`/${encode("one two")};${encode("p 1")}=${encode("v 1")};${encode("p 2")}=${encode("v 2")}`;
192+
`/${encodeUriSegment("one two")};${encodeUriSegment("p 1")}=${encodeUriSegment("v 1")};${encodeUriSegment("p 2")}=${encodeUriSegment("v 2")}`;
193193
const tree = url.parse(u);
194194

195195
expect(tree.root.children[PRIMARY_OUTLET].segments[0].path).toEqual('one two');
@@ -199,7 +199,8 @@ describe('url serializer', () => {
199199
});
200200

201201
it('should encode/decode "slash" in path segments and parameters', () => {
202-
const u = `/${encode("one/two")};${encode("p/1")}=${encode("v/1")}/three`;
202+
const u =
203+
`/${encodeUriSegment("one/two")};${encodeUriSegment("p/1")}=${encodeUriSegment("v/1")}/three`;
203204
const tree = url.parse(u);
204205
const segment = tree.root.children[PRIMARY_OUTLET].segments[0];
205206
expect(segment.path).toEqual('one/two');
@@ -210,7 +211,8 @@ describe('url serializer', () => {
210211
});
211212

212213
it('should encode/decode query params', () => {
213-
const u = `/one?${encode("p 1")}=${encode("v 1")}&${encode("p 2")}=${encode("v 2")}`;
214+
const u =
215+
`/one?${encodeUriQuery("p 1")}=${encodeUriQuery("v 1")}&${encodeUriQuery("p 2")}=${encodeUriQuery("v 2")}`;
214216
const tree = url.parse(u);
215217

216218
expect(tree.queryParams).toEqual({'p 1': 'v 1', 'p 2': 'v 2'});
@@ -219,28 +221,143 @@ describe('url serializer', () => {
219221
expect(url.serialize(tree)).toEqual(u);
220222
});
221223

224+
it('should decode spaces in query as %20 or +', () => {
225+
const u1 = `/one?foo=bar baz`;
226+
const u2 = `/one?foo=bar+baz`;
227+
const u3 = `/one?foo=bar%20baz`;
228+
229+
const u1p = url.parse(u1);
230+
const u2p = url.parse(u2);
231+
const u3p = url.parse(u3);
232+
233+
expect(url.serialize(u1p)).toBe(url.serialize(u2p));
234+
expect(url.serialize(u2p)).toBe(url.serialize(u3p));
235+
expect(u1p.queryParamMap.get('foo')).toBe('bar baz');
236+
expect(u2p.queryParamMap.get('foo')).toBe('bar baz');
237+
expect(u3p.queryParamMap.get('foo')).toBe('bar baz');
238+
});
239+
222240
it('should encode query params leaving sub-delimiters intact', () => {
223-
const percentChars = '/?#[]&+= ';
224-
const percentCharsEncoded = '%2F%3F%23%5B%5D%26%2B%3D%20';
241+
const percentChars = '/?#&+=[] ';
242+
const percentCharsEncoded = '%2F%3F%23%26%2B%3D%5B%5D%20';
225243
const intactChars = '!$\'()*,;:';
226244
const params = percentChars + intactChars;
227245
const paramsEncoded = percentCharsEncoded + intactChars;
228246
const mixedCaseString = 'sTrInG';
229247

230-
expect(percentCharsEncoded).toEqual(encode(percentChars));
231-
expect(intactChars).toEqual(encode(intactChars));
248+
expect(percentCharsEncoded).toEqual(encodeUriQuery(percentChars));
249+
expect(intactChars).toEqual(encodeUriQuery(intactChars));
232250
// Verify it replaces repeated characters correctly
233-
expect(paramsEncoded + paramsEncoded).toEqual(encode(params + params));
251+
expect(paramsEncoded + paramsEncoded).toEqual(encodeUriQuery(params + params));
234252
// Verify it doesn't change the case of alpha characters
235-
expect(mixedCaseString + paramsEncoded).toEqual(encode(mixedCaseString + params));
253+
expect(mixedCaseString + paramsEncoded).toEqual(encodeUriQuery(mixedCaseString + params));
236254
});
237255

238256
it('should encode/decode fragment', () => {
239-
const u = `/one#${encodeURI("one two=three four")}`;
257+
const u = `/one#${encodeUriQuery('one two=three four')}`;
240258
const tree = url.parse(u);
241259

242260
expect(tree.fragment).toEqual('one two=three four');
243-
expect(url.serialize(tree)).toEqual(u);
261+
expect(url.serialize(tree)).toEqual('/one#one%20two%3Dthree%20four');
262+
});
263+
});
264+
265+
describe('special character encoding/decoding', () => {
266+
267+
// Tests specific to https://github.com/angular/angular/issues/10280
268+
it('should parse encoded parens in matrix params', () => {
269+
const auxRoutesUrl = '/abc;foo=(other:val)';
270+
const fooValueUrl = '/abc;foo=%28other:val%29';
271+
272+
const auxParsed = url.parse(auxRoutesUrl).root;
273+
const fooParsed = url.parse(fooValueUrl).root;
274+
275+
276+
// Test base case
277+
expect(auxParsed.children[PRIMARY_OUTLET].segments.length).toBe(1);
278+
expect(auxParsed.children[PRIMARY_OUTLET].segments[0].path).toBe('abc');
279+
expect(auxParsed.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({foo: ''});
280+
expect(auxParsed.children['other'].segments.length).toBe(1);
281+
expect(auxParsed.children['other'].segments[0].path).toBe('val');
282+
283+
// Confirm matrix params are URL decoded
284+
expect(fooParsed.children[PRIMARY_OUTLET].segments.length).toBe(1);
285+
expect(fooParsed.children[PRIMARY_OUTLET].segments[0].path).toBe('abc');
286+
expect(fooParsed.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({
287+
foo: '(other:val)'
288+
});
289+
});
290+
291+
it('should serialize encoded parens in matrix params', () => {
292+
const testUrl = '/abc;foo=%28one%29';
293+
294+
const parsed = url.parse(testUrl);
295+
296+
expect(url.serialize(parsed)).toBe('/abc;foo=%28one%29');
297+
});
298+
299+
it('should not serialize encoded parens in query params', () => {
300+
const testUrl = '/abc?foo=%28one%29';
301+
302+
const parsed = url.parse(testUrl);
303+
304+
expect(parsed.queryParams).toEqual({foo: '(one)'});
305+
306+
expect(url.serialize(parsed)).toBe('/abc?foo=(one)');
307+
});
308+
309+
// Test special characters in general
310+
311+
// From http://www.ietf.org/rfc/rfc3986.txt
312+
const unreserved = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~`;
313+
314+
it('should encode a minimal set of special characters in queryParams and fragment', () => {
315+
const notEncoded = unreserved + `:@!$'*,();`;
316+
const encode = ` +%&=#[]/?`;
317+
const encoded = `%20%2B%25%26%3D%23%5B%5D%2F%3F`;
318+
319+
const parsed = url.parse('/foo');
320+
321+
parsed.queryParams = {notEncoded, encode};
322+
323+
expect(url.serialize(parsed)).toBe(`/foo?notEncoded=${notEncoded}&encode=${encoded}`);
324+
});
325+
326+
it('should encode a minimal set of special characters in fragment', () => {
327+
const notEncoded = unreserved + `:@!$'*,();`;
328+
const encode = ` +%&=#[]/?`;
329+
const encoded = `%20%2B%25%26%3D%23%5B%5D%2F%3F`;
330+
331+
const parsed = url.parse('/foo');
332+
333+
parsed.fragment = notEncoded + encode;
334+
335+
expect(url.serialize(parsed)).toBe(`/foo#${notEncoded}${encoded}`);
336+
});
337+
338+
it('should encode minimal special characters plus parens and semi-colon in matrix params',
339+
() => {
340+
const notEncoded = unreserved + `:@!$'*,&`;
341+
const encode = ` /%=#()[];?+`;
342+
const encoded = `%20%2F%25%3D%23%28%29%5B%5D%3B%3F%2B`;
343+
344+
const parsed = url.parse('/foo');
345+
346+
parsed.root.children[PRIMARY_OUTLET].segments[0].parameters = {notEncoded, encode};
347+
348+
expect(url.serialize(parsed)).toBe(`/foo;notEncoded=${notEncoded};encode=${encoded}`);
349+
});
350+
351+
it('should encode special characters in the path the same as matrix params', () => {
352+
const notEncoded = unreserved + `:@!$'*,&`;
353+
const encode = ` /%=#()[];?+`;
354+
const encoded = `%20%2F%25%3D%23%28%29%5B%5D%3B%3F%2B`;
355+
356+
const parsed = url.parse('/foo');
357+
358+
parsed.root.children[PRIMARY_OUTLET].segments[0].path = notEncoded + encode;
359+
360+
expect(url.serialize(parsed)).toBe(`/${notEncoded}${encoded}`);
244361
});
245362
});
246363

0 commit comments

Comments
 (0)