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 {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 {BrowserXhr} from './src/backends/browser_xhr';
import {BrowserJsonp} from './src/backends/browser_jsonp';
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';
export {Request} from './src/static_request';
export {Response} from './src/static_response';
@ -20,13 +20,14 @@ export {
RequestOptionsArgs,
ResponseOptionsArgs,
Connection,
ConnectionBackend
ConnectionBackend,
XSRFStrategy
} from './src/interfaces';
export {BrowserXhr} from './src/backends/browser_xhr';
export {BaseRequestOptions, RequestOptions} from './src/base_request_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 {Http, Jsonp} from './src/http';
@ -88,6 +89,7 @@ export {URLSearchParams} from './src/url_search_params';
* The providers included in `HTTP_PROVIDERS` include:
* * {@link Http}
* * {@link XHRBackend}
* * {@link XSRFStrategy} - Bound to {@link CookieXSRFStrategy} class (see below)
* * `BrowserXHR` - Private factory to create `XMLHttpRequest` instances
* * {@link RequestOptions} - Bound to {@link BaseRequestOptions} 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[] = [
// TODO(pascal): use factory type annotations once supported in DI
@ -164,7 +191,8 @@ export const HTTP_PROVIDERS: any[] = [
BrowserXhr,
provide(RequestOptions, {useClass: BaseRequestOptions}),
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 {Request} from '../static_request';
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.
*
@ -158,12 +181,15 @@ export class XHRConnection implements Connection {
* }
* }
* ```
*
**/
@Injectable()
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 {
this._xsrfStrategy.configureRequest(request);
return new XHRConnection(request, this._browserXHR, this._baseResponseOptions);
}
}

View File

@ -20,6 +20,11 @@ export abstract class Connection {
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
* [RequestInit](https://fetch.spec.whatwg.org/#requestinit) from the Fetch spec.

View File

@ -1,6 +1,7 @@
import {
afterEach,
beforeEach,
beforeEachProviders,
ddescribe,
describe,
expect,
@ -11,8 +12,10 @@ import {
} from '@angular/core/testing/testing_internal';
import {AsyncTestCompleter, SpyObject} from '@angular/core/testing/testing_internal';
import {BrowserXhr} from '../../src/backends/browser_xhr';
import {XHRConnection, XHRBackend} from '../../src/backends/xhr_backend';
import {provide, Injector, ReflectiveInjector} from '@angular/core';
import {XSRFStrategy} from '../../src/interfaces';
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 {Response} from '../../src/static_response';
import {Headers} from '../../src/headers';
@ -89,22 +92,59 @@ export function main() {
var backend: XHRBackend;
var sampleRequest: Request;
beforeEach(() => {
var injector = ReflectiveInjector.resolveAndCreate([
provide(ResponseOptions, {useClass: BaseResponseOptions}),
provide(BrowserXhr, {useClass: MockBrowserXHR}),
XHRBackend
]);
backend = injector.get(XHRBackend);
var base = new BaseRequestOptions();
beforeEachProviders(
() =>
[provide(ResponseOptions, {useClass: BaseResponseOptions}),
provide(BrowserXhr, {useClass: MockBrowserXHR}), XHRBackend,
provide(XSRFStrategy, {useValue: new CookieXSRFStrategy()}),
]);
beforeEach(inject([XHRBackend], (be: XHRBackend) => {
backend = be;
let base = new BaseRequestOptions();
sampleRequest = new Request(base.merge(new RequestOptions({url: 'https://google.com'})));
});
}));
afterEach(() => { existingXHRs = []; });
it('should create a connection',
() => { expect(() => backend.createConnection(sampleRequest)).not.toThrow(); });
describe('creating a connection', () => {
@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', () => {
it('should use the injected BaseResponseOptions to create the response',

View File

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

View File

@ -350,6 +350,18 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter {
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 :
'/' + 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 getTransitionEnd(): string;
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"; }
supportsAnimation(): 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'); }
invoke(el: Element, methodName: string, args: any[]): any { 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