diff --git a/modules/@angular/http/http.ts b/modules/@angular/http/http.ts index 75b1c6835f..2bdccec1bc 100644 --- a/modules/@angular/http/http.ts +++ b/modules/@angular/http/http.ts @@ -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()}), ]; /** diff --git a/modules/@angular/http/src/backends/xhr_backend.ts b/modules/@angular/http/src/backends/xhr_backend.ts index 8a997f51a7..d4c58e0d6b 100644 --- a/modules/@angular/http/src/backends/xhr_backend.ts +++ b/modules/@angular/http/src/backends/xhr_backend.ts @@ -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); } } diff --git a/modules/@angular/http/src/interfaces.ts b/modules/@angular/http/src/interfaces.ts index f11761a5bc..10337df73c 100644 --- a/modules/@angular/http/src/interfaces.ts +++ b/modules/@angular/http/src/interfaces.ts @@ -20,6 +20,11 @@ export abstract class Connection { response: any; // TODO: generic of ; } +/** 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. diff --git a/modules/@angular/http/test/backends/xhr_backend_spec.ts b/modules/@angular/http/test/backends/xhr_backend_spec.ts index 3be5576474..301b488d80 100644 --- a/modules/@angular/http/test/backends/xhr_backend_spec.ts +++ b/modules/@angular/http/test/backends/xhr_backend_spec.ts @@ -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', diff --git a/modules/@angular/integration_test/public_api_spec.ts b/modules/@angular/integration_test/public_api_spec.ts index 7e40a46e53..be89420ef9 100644 --- a/modules/@angular/integration_test/public_api_spec.ts +++ b/modules/@angular/integration_test/public_api_spec.ts @@ -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']; diff --git a/modules/@angular/platform-browser/src/browser/browser_adapter.ts b/modules/@angular/platform-browser/src/browser/browser_adapter.ts index e496d43592..4848784bd1 100644 --- a/modules/@angular/platform-browser/src/browser/browser_adapter.ts +++ b/modules/@angular/platform-browser/src/browser/browser_adapter.ts @@ -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; +} diff --git a/modules/@angular/platform-browser/src/dom/dom_adapter.ts b/modules/@angular/platform-browser/src/dom/dom_adapter.ts index a3afe14cfd..7b15cbfc47 100644 --- a/modules/@angular/platform-browser/src/dom/dom_adapter.ts +++ b/modules/@angular/platform-browser/src/dom/dom_adapter.ts @@ -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); } diff --git a/modules/@angular/platform-browser/src/web_workers/worker/worker_adapter.ts b/modules/@angular/platform-browser/src/web_workers/worker/worker_adapter.ts index 713e2c802e..f0d474e84a 100644 --- a/modules/@angular/platform-browser/src/web_workers/worker/worker_adapter.ts +++ b/modules/@angular/platform-browser/src/web_workers/worker/worker_adapter.ts @@ -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"; } } diff --git a/modules/@angular/platform-browser/test/browser/browser_adapter_spec.ts b/modules/@angular/platform-browser/test/browser/browser_adapter_spec.ts new file mode 100644 index 0000000000..8fa940462d --- /dev/null +++ b/modules/@angular/platform-browser/test/browser/browser_adapter_spec.ts @@ -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'); + }); + }); +} diff --git a/modules/@angular/platform-server/src/parse5_adapter.ts b/modules/@angular/platform-server/src/parse5_adapter.ts index 04e61cde4f..44e2d5915c 100644 --- a/modules/@angular/platform-server/src/parse5_adapter.ts +++ b/modules/@angular/platform-server/src/parse5_adapter.ts @@ -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