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:
parent
3ae29c08ac
commit
4d793c4eb8
|
@ -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()}),
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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"; }
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue