fix(router): fix URL serialization so special characters are only encoded where needed (#22337)

Fixes: #10280

This change brings Angular largely in line with how AngularJS previously serialized URLs. This is based on [RFC 3986](http://tools.ietf.org/html/rfc3986) 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:

* Query strings will now serialize with decoded slash (`/`) and question mark (`?`)
* URI fragments will now serialize the same as query strings, but hash sign (`#`) will also appear decoded
* 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.

PR Close #22337
This commit is contained in:
Jason Aden 2018-02-20 15:08:41 -08:00 committed by Victor Berchet
parent 3a809cb431
commit 094666da17
2 changed files with 166 additions and 26 deletions

View File

@ -280,7 +280,8 @@ export class DefaultUrlSerializer implements UrlSerializer {
serialize(tree: UrlTree): string { serialize(tree: UrlTree): string {
const segment = `/${serializeSegment(tree.root, true)}`; const segment = `/${serializeSegment(tree.root, true)}`;
const query = serializeQueryParams(tree.queryParams); const query = serializeQueryParams(tree.queryParams);
const fragment = typeof tree.fragment === `string` ? `#${encodeURI(tree.fragment !)}` : ''; const fragment =
typeof tree.fragment === `string` ? `#${encodeUriFragment(tree.fragment !)}` : '';
return `${segment}${query}${fragment}`; return `${segment}${query}${fragment}`;
} }
@ -326,9 +327,10 @@ function serializeSegment(segment: UrlSegmentGroup, root: boolean): string {
} }
/** /**
* This method is intended for encoding *key* or *value* parts of query component. We need a custom * Encodes a URI string with the default encoding. This function will only ever be called from
* method because encodeURIComponent is too aggressive and encodes stuff that doesn't have to be * `encodeUriQuery` or `encodeUriSegment` as it's the base set of encodings to be used. We need
* encoded per http://tools.ietf.org/html/rfc3986: * a custom encoding because encodeURIComponent is too aggressive and encodes stuff that doesn't
* have to be encoded per http://tools.ietf.org/html/rfc3986:
* query = *( pchar / "/" / "?" ) * query = *( pchar / "/" / "?" )
* pchar = unreserved / pct-encoded / sub-delims / ":" / "@" * pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
* unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
@ -336,13 +338,47 @@ function serializeSegment(segment: UrlSegmentGroup, root: boolean): string {
* sub-delims = "!" / "$" / "&" / "'" / "(" / ")" * sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
* / "*" / "+" / "," / ";" / "=" * / "*" / "+" / "," / ";" / "="
*/ */
export function encode(s: string): string { function encodeUriString(s: string): string {
return encodeURIComponent(s) return encodeURIComponent(s)
.replace(/%40/g, '@') .replace(/%40/g, '@')
.replace(/%3A/gi, ':') .replace(/%3A/gi, ':')
.replace(/%24/g, '$') .replace(/%24/g, '$')
.replace(/%2C/gi, ',') .replace(/%2C/gi, ',');
.replace(/%3B/gi, ';'); }
/**
* This function should be used to encode both keys and values in a query string key/value. In
* the following URL, you need to call encodeUriQuery on "k" and "v":
*
* http://www.site.org/html;mk=mv?k=v#f
*/
export function encodeUriQuery(s: string): string {
return encodeUriString(s).replace(/%2F/gi, '/').replace(/%3F/gi, '?').replace(/%3B/gi, ';');
}
/**
* This function should be run on any URI fragment. In the following URL, you need to call
* encodeUriSegment on "f":
*
* http://www.site.org/html;mk=mv?k=v#f
*/
export function encodeUriFragment(s: string): string {
return encodeUriQuery(s).replace(/%23/g, '#');
}
/**
* This function should be run on any URI segment as well as the key and value in a key/value
* pair for matrix params. In the following URL, you need to call encodeUriSegment on "html",
* "mk", and "mv":
*
* http://www.site.org/html;mk=mv?k=v#f
*/
export function encodeUriSegment(s: string): string {
return encodeUriString(s)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/%2B/gi, '+')
.replace(/%26/gi, '&');
} }
export function decode(s: string): string { export function decode(s: string): string {
@ -350,18 +386,21 @@ export function decode(s: string): string {
} }
export function serializePath(path: UrlSegment): string { export function serializePath(path: UrlSegment): string {
return `${encode(path.path)}${serializeParams(path.parameters)}`; return `${encodeUriSegment(path.path)}${serializeMatrixParams(path.parameters)}`;
} }
function serializeParams(params: {[key: string]: string}): string { function serializeMatrixParams(params: {[key: string]: string}): string {
return Object.keys(params).map(key => `;${encode(key)}=${encode(params[key])}`).join(''); return Object.keys(params)
.map(key => `;${encodeUriSegment(key)}=${encodeUriSegment(params[key])}`)
.join('');
} }
function serializeQueryParams(params: {[key: string]: any}): string { function serializeQueryParams(params: {[key: string]: any}): string {
const strParams: string[] = Object.keys(params).map((name) => { const strParams: string[] = Object.keys(params).map((name) => {
const value = params[name]; const value = params[name];
return Array.isArray(value) ? value.map(v => `${encode(name)}=${encode(v)}`).join('&') : return Array.isArray(value) ?
`${encode(name)}=${encode(value)}`; value.map(v => `${encodeUriQuery(name)}=${encodeUriQuery(v)}`).join('&') :
`${encodeUriQuery(name)}=${encodeUriQuery(value)}`;
}); });
return strParams.length ? `?${strParams.join("&")}` : ''; return strParams.length ? `?${strParams.join("&")}` : '';
@ -414,7 +453,7 @@ class UrlParser {
} }
parseFragment(): string|null { parseFragment(): string|null {
return this.consumeOptional('#') ? decodeURI(this.remaining) : null; return this.consumeOptional('#') ? decodeURIComponent(this.remaining) : null;
} }
private parseChildren(): {[outlet: string]: UrlSegmentGroup} { private parseChildren(): {[outlet: string]: UrlSegmentGroup} {

View File

@ -7,7 +7,7 @@
*/ */
import {PRIMARY_OUTLET} from '../src/shared'; import {PRIMARY_OUTLET} from '../src/shared';
import {DefaultUrlSerializer, UrlSegmentGroup, encode, serializePath} from '../src/url_tree'; import {DefaultUrlSerializer, UrlSegmentGroup, encodeUriQuery, encodeUriSegment, serializePath} from '../src/url_tree';
describe('url serializer', () => { describe('url serializer', () => {
const url = new DefaultUrlSerializer(); const url = new DefaultUrlSerializer();
@ -189,7 +189,7 @@ describe('url serializer', () => {
describe('encoding/decoding', () => { describe('encoding/decoding', () => {
it('should encode/decode path segments and parameters', () => { it('should encode/decode path segments and parameters', () => {
const u = const u =
`/${encode("one two")};${encode("p 1")}=${encode("v 1")};${encode("p 2")}=${encode("v 2")}`; `/${encodeUriSegment("one two")};${encodeUriSegment("p 1")}=${encodeUriSegment("v 1")};${encodeUriSegment("p 2")}=${encodeUriSegment("v 2")}`;
const tree = url.parse(u); const tree = url.parse(u);
expect(tree.root.children[PRIMARY_OUTLET].segments[0].path).toEqual('one two'); expect(tree.root.children[PRIMARY_OUTLET].segments[0].path).toEqual('one two');
@ -199,7 +199,8 @@ describe('url serializer', () => {
}); });
it('should encode/decode "slash" in path segments and parameters', () => { it('should encode/decode "slash" in path segments and parameters', () => {
const u = `/${encode("one/two")};${encode("p/1")}=${encode("v/1")}/three`; const u =
`/${encodeUriSegment("one/two")};${encodeUriSegment("p/1")}=${encodeUriSegment("v/1")}/three`;
const tree = url.parse(u); const tree = url.parse(u);
const segment = tree.root.children[PRIMARY_OUTLET].segments[0]; const segment = tree.root.children[PRIMARY_OUTLET].segments[0];
expect(segment.path).toEqual('one/two'); expect(segment.path).toEqual('one/two');
@ -210,7 +211,8 @@ describe('url serializer', () => {
}); });
it('should encode/decode query params', () => { it('should encode/decode query params', () => {
const u = `/one?${encode("p 1")}=${encode("v 1")}&${encode("p 2")}=${encode("v 2")}`; const u =
`/one?${encodeUriQuery("p 1")}=${encodeUriQuery("v 1")}&${encodeUriQuery("p 2")}=${encodeUriQuery("v 2")}`;
const tree = url.parse(u); const tree = url.parse(u);
expect(tree.queryParams).toEqual({'p 1': 'v 1', 'p 2': 'v 2'}); expect(tree.queryParams).toEqual({'p 1': 'v 1', 'p 2': 'v 2'});
@ -220,27 +222,126 @@ describe('url serializer', () => {
}); });
it('should encode query params leaving sub-delimiters intact', () => { it('should encode query params leaving sub-delimiters intact', () => {
const percentChars = '/?#[]&+= '; const percentChars = '#&+=[] ';
const percentCharsEncoded = '%2F%3F%23%5B%5D%26%2B%3D%20'; const percentCharsEncoded = '%23%26%2B%3D%5B%5D%20';
const intactChars = '!$\'()*,;:'; const intactChars = '/?!$\'()*,;:';
const params = percentChars + intactChars; const params = percentChars + intactChars;
const paramsEncoded = percentCharsEncoded + intactChars; const paramsEncoded = percentCharsEncoded + intactChars;
const mixedCaseString = 'sTrInG'; const mixedCaseString = 'sTrInG';
expect(percentCharsEncoded).toEqual(encode(percentChars)); expect(percentCharsEncoded).toEqual(encodeUriQuery(percentChars));
expect(intactChars).toEqual(encode(intactChars)); expect(intactChars).toEqual(encodeUriQuery(intactChars));
// Verify it replaces repeated characters correctly // Verify it replaces repeated characters correctly
expect(paramsEncoded + paramsEncoded).toEqual(encode(params + params)); expect(paramsEncoded + paramsEncoded).toEqual(encodeUriQuery(params + params));
// Verify it doesn't change the case of alpha characters // Verify it doesn't change the case of alpha characters
expect(mixedCaseString + paramsEncoded).toEqual(encode(mixedCaseString + params)); expect(mixedCaseString + paramsEncoded).toEqual(encodeUriQuery(mixedCaseString + params));
}); });
it('should encode/decode fragment', () => { it('should encode/decode fragment', () => {
const u = `/one#${encodeURI("one two=three four")}`; const u = `/one#${encodeUriQuery('one two=three four')}`;
const tree = url.parse(u); const tree = url.parse(u);
expect(tree.fragment).toEqual('one two=three four'); expect(tree.fragment).toEqual('one two=three four');
expect(url.serialize(tree)).toEqual(u); expect(url.serialize(tree)).toEqual('/one#one%20two%3Dthree%20four');
});
});
describe('special character encoding/decoding', () => {
// Tests specific to https://github.com/angular/angular/issues/10280
it('should parse encoded parens in matrix params', () => {
const auxRoutesUrl = '/abc;foo=(other:val)';
const fooValueUrl = '/abc;foo=%28other:val%29';
const auxParsed = url.parse(auxRoutesUrl).root;
const fooParsed = url.parse(fooValueUrl).root;
// Test base case
expect(auxParsed.children[PRIMARY_OUTLET].segments.length).toBe(1);
expect(auxParsed.children[PRIMARY_OUTLET].segments[0].path).toBe('abc');
expect(auxParsed.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({foo: ''});
expect(auxParsed.children['other'].segments.length).toBe(1);
expect(auxParsed.children['other'].segments[0].path).toBe('val');
// Confirm matrix params are URL decoded
expect(fooParsed.children[PRIMARY_OUTLET].segments.length).toBe(1);
expect(fooParsed.children[PRIMARY_OUTLET].segments[0].path).toBe('abc');
expect(fooParsed.children[PRIMARY_OUTLET].segments[0].parameters).toEqual({
foo: '(other:val)'
});
});
it('should serialize encoded parens in matrix params', () => {
const testUrl = '/abc;foo=%28one%29';
const parsed = url.parse(testUrl);
expect(url.serialize(parsed)).toBe('/abc;foo=%28one%29');
});
it('should not serialize encoded parens in query params', () => {
const testUrl = '/abc?foo=%28one%29';
const parsed = url.parse(testUrl);
expect(parsed.queryParams).toEqual({foo: '(one)'});
expect(url.serialize(parsed)).toBe('/abc?foo=(one)');
});
// Test special characters in general
// From http://www.ietf.org/rfc/rfc3986.txt
const unreserved = `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~`;
it('should encode a minimal set of special characters in queryParams', () => {
const notEncoded = unreserved + `:@!$'*,()/?;`;
const encode = ` +%&=#[]`;
const encoded = `%20%2B%25%26%3D%23%5B%5D`;
const parsed = url.parse('/foo');
parsed.queryParams = {notEncoded, encode};
expect(url.serialize(parsed)).toBe(`/foo?notEncoded=${notEncoded}&encode=${encoded}`);
});
it('should encode a minimal set of special characters in fragment', () => {
const notEncoded = unreserved + `:@!$'*,()/?;#`;
const encode = ` +%&=[]`;
const encoded = `%20%2B%25%26%3D%5B%5D`;
const parsed = url.parse('/foo');
parsed.fragment = notEncoded + encode;
expect(url.serialize(parsed)).toBe(`/foo#${notEncoded}${encoded}`);
});
it('should encode minimal special characters plus parens and semi-colon in matrix params',
() => {
const notEncoded = unreserved + `:@!$'*,+&`;
const encode = ` /%=#()[];?`;
const encoded = `%20%2F%25%3D%23%28%29%5B%5D%3B%3F`;
const parsed = url.parse('/foo');
parsed.root.children[PRIMARY_OUTLET].segments[0].parameters = {notEncoded, encode};
expect(url.serialize(parsed)).toBe(`/foo;notEncoded=${notEncoded};encode=${encoded}`);
});
it('should encode special characters in the path the same as matrix params', () => {
const notEncoded = unreserved + `:@!$'*,+&`;
const encode = ` /%=#()[];?`;
const encoded = `%20%2F%25%3D%23%28%29%5B%5D%3B%3F`;
const parsed = url.parse('/foo');
parsed.root.children[PRIMARY_OUTLET].segments[0].path = notEncoded + encode;
expect(url.serialize(parsed)).toBe(`/${notEncoded}${encoded}`);
}); });
}); });