fix(http): don't encode values that are allowed in query (#9651)

This implements a new class, QueryEncoder, that provides
methods for encoding keys and values of query parameter.
The encoder encodes with encodeURIComponent, and then
decodes a whitelist of allowed characters back to their
unencoded form.

BREAKING CHANGE:

The changes to Http's URLSearchParams serialization now 
prevent encoding of these characters inside query parameters
which were previously converted to percent-encoded values:

@ : $ , ; + ; ? /

The default encoding behavior can be overridden by extending
QueryEncoder, as documented in the URLSearchParams service.

Fixes #9348
This commit is contained in:
Jeff Cross 2016-06-28 11:31:35 -07:00 committed by GitHub
parent bf598d6b8b
commit 1620426393
4 changed files with 108 additions and 6 deletions

View File

@ -34,7 +34,8 @@ export {Http, Jsonp} from './src/http';
export {Connection, ConnectionBackend, RequestOptionsArgs, ResponseOptionsArgs, XSRFStrategy} from './src/interfaces';
export {Request} from './src/static_request';
export {Response} from './src/static_response';
export {URLSearchParams} from './src/url_search_params';
export {QueryEncoder, URLSearchParams} from './src/url_search_params';
/**

View File

@ -14,7 +14,7 @@ function paramParser(rawParams: string = ''): Map<string, string[]> {
if (rawParams.length > 0) {
var params: string[] = rawParams.split('&');
params.forEach((param: string) => {
var split: string[] = param.split('=');
var split: string[] = param.split('=', 2);
var key = split[0];
var val = split[1];
var list = isPresent(map.get(key)) ? map.get(key) : [];
@ -24,6 +24,27 @@ function paramParser(rawParams: string = ''): Map<string, string[]> {
}
return map;
}
/**
* @experimental
**/
export class QueryEncoder {
encodeKey(k: string): string { return standardEncoding(k); }
encodeValue(v: string): string { return standardEncoding(v); }
}
function standardEncoding(v: string): string {
return encodeURIComponent(v)
.replace(/%40/gi, '@')
.replace(/%3A/gi, ':')
.replace(/%24/gi, '$')
.replace(/%2C/gi, ',')
.replace(/%3B/gi, ';')
.replace(/%2B/gi, '+')
.replace(/%3D/gi, ';')
.replace(/%3F/gi, '?')
.replace(/%2F/gi, '/');
}
/**
* Map-like representation of url search parameters, based on
@ -33,11 +54,39 @@ function paramParser(rawParams: string = ''): Map<string, string[]> {
* - appendAll()
* - replaceAll()
*
* This class accepts an optional second parameter of ${@link QueryEncoder},
* which is used to serialize parameters before making a request. By default,
* `QueryEncoder` encodes keys and values of parameters using `encodeURIComponent`,
* and then un-encodes certain characters that are allowed to be part of the query
* according to IETF RFC 3986: https://tools.ietf.org/html/rfc3986.
*
* These are the characters that are not encoded: `! $ \' ( ) * + , ; A 9 - . _ ~ ? /`
*
* If the set of allowed query characters is not acceptable for a particular backend,
* `QueryEncoder` can be subclassed and provided as the 2nd argument to URLSearchParams.
*
* ```
* import {URLSearchParams, QueryEncoder} from '@angular/http';
* class MyQueryEncoder extends QueryEncoder {
* encodeKey(k: string): string {
* return myEncodingFunction(k);
* }
*
* encodeValue(v: string): string {
* return myEncodingFunction(v);
* }
* }
*
* let params = new URLSearchParams('', new MyQueryEncoder());
* ```
* @experimental
*/
export class URLSearchParams {
paramsMap: Map<string, string[]>;
constructor(public rawParams: string = '') { this.paramsMap = paramParser(rawParams); }
constructor(
public rawParams: string = '', private queryEncoder: QueryEncoder = new QueryEncoder()) {
this.paramsMap = paramParser(rawParams);
}
clone(): URLSearchParams {
var clone = new URLSearchParams();
@ -133,7 +182,9 @@ export class URLSearchParams {
toString(): string {
var paramsList: string[] = [];
this.paramsMap.forEach((values, k) => {
values.forEach(v => paramsList.push(encodeURIComponent(k) + '=' + encodeURIComponent(v)));
values.forEach(
v => paramsList.push(
this.queryEncoder.encodeKey(k) + '=' + this.queryEncoder.encodeValue(v)));
});
return paramsList.join('&');
}

View File

@ -34,12 +34,56 @@ export function main() {
});
it('should optionally accept a custom parser', () => {
let fooEveryThingParser = {
encodeKey() { return 'I AM KEY'; },
encodeValue() { return 'I AM VALUE'; }
};
let params = new URLSearchParams('', fooEveryThingParser);
params.set('myKey', 'myValue');
expect(params.toString()).toBe('I AM KEY=I AM VALUE');
});
it('should encode special characters in params', () => {
var searchParams = new URLSearchParams();
searchParams.append('a', '1+1');
searchParams.append('b c', '2');
searchParams.append('d%', '3$');
expect(searchParams.toString()).toEqual('a=1%2B1&b%20c=2&d%25=3%24');
expect(searchParams.toString()).toEqual('a=1+1&b%20c=2&d%25=3$');
});
it('should not encode allowed characters', () => {
/*
* https://tools.ietf.org/html/rfc3986#section-3.4
* Allowed: ( pchar / "/" / "?" )
* pchar: unreserved / pct-encoded / sub-delims / ":" / "@"
* unreserved: ALPHA / DIGIT / "-" / "." / "_" / "~"
* pct-encoded: "%" HEXDIG HEXDIG
* sub-delims: "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
*
* & and = are excluded and should be encoded inside keys and values
* because URLSearchParams is responsible for inserting this.
**/
let params = new URLSearchParams();
'! $ \' ( ) * + , ; A 9 - . _ ~ ? /'.split(' ').forEach(
(char, idx) => { params.set(`a${idx}`, char); });
expect(params.toString())
.toBe(
`a0=!&a1=$&a2=\'&a3=(&a4=)&a5=*&a6=+&a7=,&a8=;&a9=A&a10=9&a11=-&a12=.&a13=_&a14=~&a15=?&a16=/`
.replace(/\s/g, ''));
// Original example from https://github.com/angular/angular/issues/9348 for posterity
params = new URLSearchParams();
params.set('q', 'repo:janbaer/howcani+type:issue');
params.set('sort', 'created');
params.set('order', 'desc');
params.set('page', '1');
expect(params.toString())
.toBe('q=repo:janbaer/howcani+type:issue&sort=created&order=desc&page=1');
});

View File

@ -100,6 +100,12 @@ export declare abstract class JSONPConnection implements Connection {
abstract finished(data?: any): void;
}
/** @experimental */
export declare class QueryEncoder {
encodeKey(k: string): string;
encodeValue(v: string): string;
}
/** @experimental */
export declare enum ReadyState {
Unsent = 0,
@ -209,7 +215,7 @@ export declare enum ResponseType {
export declare class URLSearchParams {
paramsMap: Map<string, string[]>;
rawParams: string;
constructor(rawParams?: string);
constructor(rawParams?: string, queryEncoder?: QueryEncoder);
append(param: string, val: string): void;
appendAll(searchParams: URLSearchParams): void;
clone(): URLSearchParams;