feat(security): Automatic XSRF handling.

Automatically recognize XSRF protection cookies, and set a corresponding XSRF
header. Allows applications to configure the cookie names, or if needed,
completely override the XSRF request configuration by binding their own
XSRFHandler implementation.

Part of #8511.
This commit is contained in:
Martin Probst 2016-05-27 20:15:40 -07:00
parent 3ae29c08ac
commit 4d793c4eb8
10 changed files with 195 additions and 22 deletions

View File

@ -6,12 +6,12 @@
*/ */
import {provide} from '@angular/core'; import {provide} from '@angular/core';
import {Http, Jsonp} from './src/http'; import {Http, Jsonp} from './src/http';
import {XHRBackend, XHRConnection} from './src/backends/xhr_backend'; import {XHRBackend, XHRConnection, CookieXSRFStrategy} from './src/backends/xhr_backend';
import {JSONPBackend, JSONPBackend_, JSONPConnection} from './src/backends/jsonp_backend'; import {JSONPBackend, JSONPBackend_, JSONPConnection} from './src/backends/jsonp_backend';
import {BrowserXhr} from './src/backends/browser_xhr'; import {BrowserXhr} from './src/backends/browser_xhr';
import {BrowserJsonp} from './src/backends/browser_jsonp'; import {BrowserJsonp} from './src/backends/browser_jsonp';
import {BaseRequestOptions, RequestOptions} from './src/base_request_options'; import {BaseRequestOptions, RequestOptions} from './src/base_request_options';
import {ConnectionBackend} from './src/interfaces'; import {ConnectionBackend, XSRFStrategy} from './src/interfaces';
import {BaseResponseOptions, ResponseOptions} from './src/base_response_options'; import {BaseResponseOptions, ResponseOptions} from './src/base_response_options';
export {Request} from './src/static_request'; export {Request} from './src/static_request';
export {Response} from './src/static_response'; export {Response} from './src/static_response';
@ -20,13 +20,14 @@ export {
RequestOptionsArgs, RequestOptionsArgs,
ResponseOptionsArgs, ResponseOptionsArgs,
Connection, Connection,
ConnectionBackend ConnectionBackend,
XSRFStrategy
} from './src/interfaces'; } from './src/interfaces';
export {BrowserXhr} from './src/backends/browser_xhr'; export {BrowserXhr} from './src/backends/browser_xhr';
export {BaseRequestOptions, RequestOptions} from './src/base_request_options'; export {BaseRequestOptions, RequestOptions} from './src/base_request_options';
export {BaseResponseOptions, ResponseOptions} from './src/base_response_options'; export {BaseResponseOptions, ResponseOptions} from './src/base_response_options';
export {XHRBackend, XHRConnection} from './src/backends/xhr_backend'; export {XHRBackend, XHRConnection, CookieXSRFStrategy} from './src/backends/xhr_backend';
export {JSONPBackend, JSONPConnection} from './src/backends/jsonp_backend'; export {JSONPBackend, JSONPConnection} from './src/backends/jsonp_backend';
export {Http, Jsonp} from './src/http'; export {Http, Jsonp} from './src/http';
@ -88,6 +89,7 @@ export {URLSearchParams} from './src/url_search_params';
* The providers included in `HTTP_PROVIDERS` include: * The providers included in `HTTP_PROVIDERS` include:
* * {@link Http} * * {@link Http}
* * {@link XHRBackend} * * {@link XHRBackend}
* * {@link XSRFStrategy} - Bound to {@link CookieXSRFStrategy} class (see below)
* * `BrowserXHR` - Private factory to create `XMLHttpRequest` instances * * `BrowserXHR` - Private factory to create `XMLHttpRequest` instances
* * {@link RequestOptions} - Bound to {@link BaseRequestOptions} class * * {@link RequestOptions} - Bound to {@link BaseRequestOptions} class
* * {@link ResponseOptions} - Bound to {@link BaseResponseOptions} class * * {@link ResponseOptions} - Bound to {@link BaseResponseOptions} class
@ -151,6 +153,31 @@ export {URLSearchParams} from './src/url_search_params';
* } * }
* }); * });
* ``` * ```
*
* `XSRFStrategy` allows customizing how the application protects itself against Cross Site Request
* Forgery (XSRF) attacks. By default, Angular will look for a cookie called `'XSRF-TOKEN'`, and set
* an HTTP request header called `'X-XSRF-TOKEN'` with the value of the cookie on each request,
* allowing the server side to validate that the request comes from its own front end.
*
* Applications can override the names used by configuring a different `XSRFStrategy` instance. Most
* commonly, applications will configure a `CookieXSRFStrategy` with different cookie or header
* names, but if needed, they can supply a completely custom implementation.
*
* See the security documentation for more information.
*
* ### Example
*
* ```
* import {provide} from '@angular/core';
* import {bootstrap} from '@angular/platform-browser/browser';
* import {HTTP_PROVIDERS, XSRFStrategy, CookieXSRFStrategy} from '@angular/http';
*
* bootstrap(
* App,
* [HTTP_PROVIDERS, provide(XSRFStrategy,
* {useValue: new CookieXSRFStrategy('MY-XSRF-COOKIE-NAME', 'X-MY-XSRF-HEADER-NAME')})])
* .catch(err => console.error(err));
* ```
*/ */
export const HTTP_PROVIDERS: any[] = [ export const HTTP_PROVIDERS: any[] = [
// TODO(pascal): use factory type annotations once supported in DI // TODO(pascal): use factory type annotations once supported in DI
@ -164,7 +191,8 @@ export const HTTP_PROVIDERS: any[] = [
BrowserXhr, BrowserXhr,
provide(RequestOptions, {useClass: BaseRequestOptions}), provide(RequestOptions, {useClass: BaseRequestOptions}),
provide(ResponseOptions, {useClass: BaseResponseOptions}), provide(ResponseOptions, {useClass: BaseResponseOptions}),
XHRBackend XHRBackend,
provide(XSRFStrategy, {useValue: new CookieXSRFStrategy()}),
]; ];
/** /**

View File

@ -1,4 +1,6 @@
import {ConnectionBackend, Connection} from '../interfaces'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {ConnectionBackend, Connection, XSRFStrategy} from '../interfaces';
import {ReadyState, RequestMethod, ResponseType, ContentType} from '../enums'; import {ReadyState, RequestMethod, ResponseType, ContentType} from '../enums';
import {Request} from '../static_request'; import {Request} from '../static_request';
import {Response} from '../static_response'; import {Response} from '../static_response';
@ -134,6 +136,27 @@ export class XHRConnection implements Connection {
} }
} }
/**
* `XSRFConfiguration` sets up Cross Site Request Forgery (XSRF) protection for the application
* using a cookie. See https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF) for more
* information on XSRF.
*
* Applications can configure custom cookie and header names by binding an instance of this class
* with different `cookieName` and `headerName` values. See the main HTTP documentation for more
* details.
*/
export class CookieXSRFStrategy implements XSRFStrategy {
constructor(
private _cookieName: string = 'XSRF-TOKEN', private _headerName: string = 'X-XSRF-TOKEN') {}
configureRequest(req: Request) {
let xsrfToken = getDOM().getCookie(this._cookieName);
if (xsrfToken && !req.headers.has(this._headerName)) {
req.headers.set(this._headerName, xsrfToken);
}
}
}
/** /**
* Creates {@link XHRConnection} instances. * Creates {@link XHRConnection} instances.
* *
@ -158,12 +181,15 @@ export class XHRConnection implements Connection {
* } * }
* } * }
* ``` * ```
*
**/ **/
@Injectable() @Injectable()
export class XHRBackend implements ConnectionBackend { export class XHRBackend implements ConnectionBackend {
constructor(private _browserXHR: BrowserXhr, private _baseResponseOptions: ResponseOptions) {} constructor(
private _browserXHR: BrowserXhr, private _baseResponseOptions: ResponseOptions,
private _xsrfStrategy: XSRFStrategy) {}
createConnection(request: Request): XHRConnection { createConnection(request: Request): XHRConnection {
this._xsrfStrategy.configureRequest(request);
return new XHRConnection(request, this._browserXHR, this._baseResponseOptions); return new XHRConnection(request, this._browserXHR, this._baseResponseOptions);
} }
} }

View File

@ -20,6 +20,11 @@ export abstract class Connection {
response: any; // TODO: generic of <Response>; response: any; // TODO: generic of <Response>;
} }
/** An XSRFStrategy configures XSRF protection (e.g. via headers) on an HTTP request. */
export abstract class XSRFStrategy {
abstract configureRequest(req: Request): void;
}
/** /**
* Interface for options to construct a RequestOptions, based on * Interface for options to construct a RequestOptions, based on
* [RequestInit](https://fetch.spec.whatwg.org/#requestinit) from the Fetch spec. * [RequestInit](https://fetch.spec.whatwg.org/#requestinit) from the Fetch spec.

View File

@ -1,6 +1,7 @@
import { import {
afterEach, afterEach,
beforeEach, beforeEach,
beforeEachProviders,
ddescribe, ddescribe,
describe, describe,
expect, expect,
@ -11,8 +12,10 @@ import {
} from '@angular/core/testing/testing_internal'; } from '@angular/core/testing/testing_internal';
import {AsyncTestCompleter, SpyObject} from '@angular/core/testing/testing_internal'; import {AsyncTestCompleter, SpyObject} from '@angular/core/testing/testing_internal';
import {BrowserXhr} from '../../src/backends/browser_xhr'; import {BrowserXhr} from '../../src/backends/browser_xhr';
import {XHRConnection, XHRBackend} from '../../src/backends/xhr_backend'; import {XSRFStrategy} from '../../src/interfaces';
import {provide, Injector, ReflectiveInjector} from '@angular/core'; import {XHRConnection, XHRBackend, CookieXSRFStrategy} from '../../src/backends/xhr_backend';
import {provide, Injector, Injectable, ReflectiveInjector} from '@angular/core';
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {Request} from '../../src/static_request'; import {Request} from '../../src/static_request';
import {Response} from '../../src/static_response'; import {Response} from '../../src/static_response';
import {Headers} from '../../src/headers'; import {Headers} from '../../src/headers';
@ -89,22 +92,59 @@ export function main() {
var backend: XHRBackend; var backend: XHRBackend;
var sampleRequest: Request; var sampleRequest: Request;
beforeEach(() => { beforeEachProviders(
var injector = ReflectiveInjector.resolveAndCreate([ () =>
provide(ResponseOptions, {useClass: BaseResponseOptions}), [provide(ResponseOptions, {useClass: BaseResponseOptions}),
provide(BrowserXhr, {useClass: MockBrowserXHR}), provide(BrowserXhr, {useClass: MockBrowserXHR}), XHRBackend,
XHRBackend provide(XSRFStrategy, {useValue: new CookieXSRFStrategy()}),
]); ]);
backend = injector.get(XHRBackend);
var base = new BaseRequestOptions(); beforeEach(inject([XHRBackend], (be: XHRBackend) => {
backend = be;
let base = new BaseRequestOptions();
sampleRequest = new Request(base.merge(new RequestOptions({url: 'https://google.com'}))); sampleRequest = new Request(base.merge(new RequestOptions({url: 'https://google.com'})));
}); }));
afterEach(() => { existingXHRs = []; }); afterEach(() => { existingXHRs = []; });
it('should create a connection', describe('creating a connection', () => {
() => { expect(() => backend.createConnection(sampleRequest)).not.toThrow(); }); @Injectable()
class NoopXsrfStrategy implements XSRFStrategy {
configureRequest(req: Request) {}
}
beforeEachProviders(() => [provide(XSRFStrategy, {useClass: NoopXsrfStrategy})]);
it('succeeds',
() => { expect(() => backend.createConnection(sampleRequest)).not.toThrow(); });
});
if (getDOM().supportsCookies()) {
describe('XSRF support', () => {
it('sets an XSRF header by default', () => {
getDOM().setCookie('XSRF-TOKEN', 'magic XSRF value');
backend.createConnection(sampleRequest);
expect(sampleRequest.headers.get('X-XSRF-TOKEN')).toBe('magic XSRF value');
});
it('respects existing headers', () => {
getDOM().setCookie('XSRF-TOKEN', 'magic XSRF value');
sampleRequest.headers.set('X-XSRF-TOKEN', 'already set');
backend.createConnection(sampleRequest);
expect(sampleRequest.headers.get('X-XSRF-TOKEN')).toBe('already set');
});
describe('configuration', () => {
beforeEachProviders(
() => [provide(
XSRFStrategy, {useValue: new CookieXSRFStrategy('my cookie', 'X-MY-HEADER')})]);
it('uses the configured names', () => {
getDOM().setCookie('my cookie', 'XSRF value');
backend.createConnection(sampleRequest);
expect(sampleRequest.headers.get('X-MY-HEADER')).toBe('XSRF value');
});
})
});
}
describe('XHRConnection', () => { describe('XHRConnection', () => {
it('should use the injected BaseResponseOptions to create the response', it('should use the injected BaseResponseOptions to create the response',

View File

@ -442,6 +442,7 @@ var HTTP: string[] = [
'BrowserXhr', 'BrowserXhr',
'Connection', 'Connection',
'ConnectionBackend', 'ConnectionBackend',
'CookieXSRFStrategy',
'HTTP_BINDINGS', 'HTTP_BINDINGS',
'HTTP_PROVIDERS', 'HTTP_PROVIDERS',
'Headers', 'Headers',
@ -460,7 +461,8 @@ var HTTP: string[] = [
'ResponseType', 'ResponseType',
'URLSearchParams', 'URLSearchParams',
'XHRBackend', 'XHRBackend',
'XHRConnection' 'XHRConnection',
'XSRFStrategy',
]; ];
var HTTP_TESTING: string[] = ['MockBackend', 'MockConnection']; var HTTP_TESTING: string[] = ['MockBackend', 'MockConnection'];

View File

@ -350,6 +350,18 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter {
return DateWrapper.toMillis(DateWrapper.now()); return DateWrapper.toMillis(DateWrapper.now());
} }
} }
supportsCookies(): boolean { return true; }
getCookie(name: string): string {
return parseCookieValue(document.cookie, name);
}
setCookie(name: string, value: string) {
// document.cookie is magical, assigning into it assigns/overrides one cookie value, but does
// not clear other cookies.
document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
} }
@ -374,3 +386,15 @@ function relativePath(url): string {
return (urlParsingNode.pathname.charAt(0) === '/') ? urlParsingNode.pathname : return (urlParsingNode.pathname.charAt(0) === '/') ? urlParsingNode.pathname :
'/' + urlParsingNode.pathname; '/' + urlParsingNode.pathname;
} }
export function parseCookieValue(cookie: string, name: string): string {
name = encodeURIComponent(name);
let cookies = cookie.split(';');
for (let cookie of cookies) {
let [key, value] = cookie.split('=', 2);
if (key.trim() === name) {
return decodeURIComponent(value);
}
}
return null;
}

View File

@ -152,4 +152,8 @@ export abstract class DomAdapter {
abstract getAnimationPrefix(): string; abstract getAnimationPrefix(): string;
abstract getTransitionEnd(): string; abstract getTransitionEnd(): string;
abstract supportsAnimation(): boolean; abstract supportsAnimation(): boolean;
abstract supportsCookies(): boolean;
abstract getCookie(name: string): string;
abstract setCookie(name: string, value: string);
} }

View File

@ -153,4 +153,8 @@ export class WorkerDomAdapter extends DomAdapter {
getTransitionEnd(): string { throw "not implemented"; } getTransitionEnd(): string { throw "not implemented"; }
supportsAnimation(): boolean { throw "not implemented"; } supportsAnimation(): boolean { throw "not implemented"; }
supportsWebAnimation(): boolean { throw "not implemented"; } supportsWebAnimation(): boolean { throw "not implemented"; }
supportsCookies(): boolean { return false; }
getCookie(name: string): string { throw "not implemented"; }
setCookie(name: string, value: string) { throw "not implemented"; }
} }

View File

@ -0,0 +1,36 @@
import {
beforeEach,
afterEach,
describe,
ddescribe,
expect,
inject,
it,
iit,
AsyncTestCompleter
} from "@angular/core/testing/testing_internal";
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {parseCookieValue} from "../../src/browser/browser_adapter";
export function main() {
describe('cookies', () => {
it('parses cookies', () => {
let cookie = 'other-cookie=false; xsrf-token=token-value; is_awesome=true; ffo=true;';
expect(parseCookieValue(cookie, 'xsrf-token')).toBe('token-value');
});
it('handles encoded keys', () => {
expect(parseCookieValue('whitespace%20token=token-value', 'whitespace token'))
.toBe('token-value');
});
it('handles encoded values', () => {
expect(parseCookieValue('token=whitespace%20', 'token')).toBe('whitespace ');
expect(parseCookieValue('token=whitespace%0A', 'token')).toBe('whitespace\n');
});
it('sets cookie values', () => {
getDOM().setCookie('my test cookie', 'my test value');
getDOM().setCookie('my other cookie', 'my test value 2');
expect(getDOM().getCookie('my test cookie')).toBe('my test value');
});
});
}

View File

@ -563,6 +563,10 @@ export class Parse5DomAdapter extends DomAdapter {
parse(templateHtml: string) { throw new Error('not implemented'); } parse(templateHtml: string) { throw new Error('not implemented'); }
invoke(el: Element, methodName: string, args: any[]): any { throw new Error('not implemented'); } invoke(el: Element, methodName: string, args: any[]): any { throw new Error('not implemented'); }
getEventKey(event): string { throw new Error('not implemented'); } getEventKey(event): string { throw new Error('not implemented'); }
supportsCookies(): boolean { return false; }
getCookie(name: string): string { throw new Error('not implemented'); }
setCookie(name: string, value: string) { throw new Error('not implemented'); }
} }
// TODO: build a proper list, this one is all the keys of a HTMLInputElement // TODO: build a proper list, this one is all the keys of a HTMLInputElement