From dd04f0948318eca60108e0948cc16552c8b9e5e6 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 13 Jul 2017 17:22:02 -0700 Subject: [PATCH] feat(common): on-by-default XSRF support in HttpClient (#18108) Fixes #18100 --- packages/common/http/public_api.ts | 3 +- packages/common/http/src/interceptor.ts | 9 +- packages/common/http/src/module.ts | 66 ++++++++++++- packages/common/http/src/xsrf.ts | 93 +++++++++++++++++++ packages/common/http/test/xsrf_spec.ts | 88 ++++++++++++++++++ packages/common/src/common.ts | 1 + packages/common/src/cookie.ts | 20 ++++ packages/common/test/cookie_spec.ts | 37 ++++++++ .../src/browser/browser_adapter.ts | 15 +-- .../test/browser/browser_adapter_spec.ts | 13 --- tools/public_api_guard/common/http.d.ts | 14 +++ 11 files changed, 328 insertions(+), 31 deletions(-) create mode 100644 packages/common/http/src/xsrf.ts create mode 100644 packages/common/http/test/xsrf_spec.ts create mode 100644 packages/common/src/cookie.ts create mode 100644 packages/common/test/cookie_spec.ts diff --git a/packages/common/http/public_api.ts b/packages/common/http/public_api.ts index ebc2e7cd20..531ff077f1 100644 --- a/packages/common/http/public_api.ts +++ b/packages/common/http/public_api.ts @@ -11,8 +11,9 @@ export {HttpClient} from './src/client'; export {HttpHeaders} from './src/headers'; export {HTTP_INTERCEPTORS, HttpInterceptor} from './src/interceptor'; export {JsonpClientBackend, JsonpInterceptor} from './src/jsonp'; -export {HttpClientJsonpModule, HttpClientModule, interceptingHandler as ɵinterceptingHandler} from './src/module'; +export {HttpClientJsonpModule, HttpClientModule, HttpXsrfModule, interceptingHandler as ɵinterceptingHandler} from './src/module'; export {HttpParameterCodec, HttpParams, HttpUrlEncodingCodec} from './src/params'; export {HttpRequest} from './src/request'; export {HttpDownloadProgressEvent, HttpErrorResponse, HttpEvent, HttpEventType, HttpHeaderResponse, HttpProgressEvent, HttpResponse, HttpResponseBase, HttpSentEvent, HttpUserEvent} from './src/response'; export {HttpXhrBackend, XhrFactory} from './src/xhr'; +export {HttpXsrfTokenExtractor} from './src/xsrf'; \ No newline at end of file diff --git a/packages/common/http/src/interceptor.ts b/packages/common/http/src/interceptor.ts index d8ba5e8a9f..f084dc3e3e 100644 --- a/packages/common/http/src/interceptor.ts +++ b/packages/common/http/src/interceptor.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {InjectionToken} from '@angular/core'; +import {Injectable, InjectionToken} from '@angular/core'; import {Observable} from 'rxjs/Observable'; import {HttpHandler} from './backend'; @@ -64,3 +64,10 @@ export class HttpInterceptorHandler implements HttpHandler { * @experimental */ export const HTTP_INTERCEPTORS = new InjectionToken('HTTP_INTERCEPTORS'); + +@Injectable() +export class NoopInterceptor implements HttpInterceptor { + intercept(req: HttpRequest, next: HttpHandler): Observable> { + return next.handle(req); + } +} diff --git a/packages/common/http/src/module.ts b/packages/common/http/src/module.ts index c600b44b1d..fa63fc491d 100644 --- a/packages/common/http/src/module.ts +++ b/packages/common/http/src/module.ts @@ -6,13 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {Inject, NgModule, Optional} from '@angular/core'; +import {Inject, ModuleWithProviders, NgModule, Optional, forwardRef} from '@angular/core'; import {HttpBackend, HttpHandler} from './backend'; import {HttpClient} from './client'; -import {HTTP_INTERCEPTORS, HttpInterceptor, HttpInterceptorHandler} from './interceptor'; +import {HTTP_INTERCEPTORS, HttpInterceptor, HttpInterceptorHandler, NoopInterceptor} from './interceptor'; import {JsonpCallbackContext, JsonpClientBackend, JsonpInterceptor} from './jsonp'; import {BrowserXhr, HttpXhrBackend, XhrFactory} from './xhr'; +import {HttpXsrfCookieExtractor, HttpXsrfInterceptor, HttpXsrfTokenExtractor, XSRF_COOKIE_NAME, XSRF_HEADER_NAME} from './xsrf'; + /** @@ -47,6 +49,58 @@ export function jsonpCallbackContext(): Object { return {}; } +/** + * `NgModule` which adds XSRF protection support to outgoing requests. + * + * Provided the server supports a cookie-based XSRF protection system, this + * module can be used directly to configure XSRF protection with the correct + * cookie and header names. + * + * If no such names are provided, the default is to use `X-XSRF-TOKEN` for + * the header name and `XSRF-TOKEN` for the cookie name. + * + * @experimental + */ +@NgModule({ + providers: [ + HttpXsrfInterceptor, + {provide: HTTP_INTERCEPTORS, useExisting: HttpXsrfInterceptor, multi: true}, + {provide: HttpXsrfTokenExtractor, useClass: HttpXsrfCookieExtractor}, + {provide: XSRF_COOKIE_NAME, useValue: 'XSRF-TOKEN'}, + {provide: XSRF_HEADER_NAME, useValue: 'X-XSRF-TOKEN'}, + ], +}) +export class HttpXsrfModule { + /** + * Disable the default XSRF protection. + */ + static disable(): ModuleWithProviders { + return { + ngModule: HttpXsrfModule, + providers: [ + {provide: HttpXsrfInterceptor, useClass: NoopInterceptor}, + ], + }; + } + + /** + * Configure XSRF protection to use the given cookie name or header name, + * or the default names (as described above) if not provided. + */ + static withOptions(options: { + cookieName?: string, + headerName?: string, + } = {}): ModuleWithProviders { + return { + ngModule: HttpXsrfModule, + providers: [ + options.cookieName ? {provide: XSRF_COOKIE_NAME, useValue: options.cookieName} : [], + options.headerName ? {provide: XSRF_HEADER_NAME, useValue: options.headerName} : [], + ], + }; + } +} + /** * `NgModule` which provides the `HttpClient` and associated services. * @@ -56,6 +110,12 @@ export function jsonpCallbackContext(): Object { * @experimental */ @NgModule({ + imports: [ + HttpXsrfModule.withOptions({ + cookieName: 'XSRF-TOKEN', + headerName: 'X-XSRF-TOKEN', + }), + ], providers: [ HttpClient, // HttpHandler is the backend + interceptors and is constructed @@ -90,4 +150,4 @@ export class HttpClientModule { ], }) export class HttpClientJsonpModule { -} +} \ No newline at end of file diff --git a/packages/common/http/src/xsrf.ts b/packages/common/http/src/xsrf.ts new file mode 100644 index 0000000000..ee213cb42e --- /dev/null +++ b/packages/common/http/src/xsrf.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DOCUMENT, ɵparseCookieValue as parseCookieValue} from '@angular/common'; +import {Inject, Injectable, InjectionToken, PLATFORM_ID} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; + +import {HttpHandler} from './backend'; +import {HttpInterceptor} from './interceptor'; +import {HttpRequest} from './request'; +import {HttpEvent} from './response'; + +export const XSRF_COOKIE_NAME = new InjectionToken('XSRF_COOKIE_NAME'); +export const XSRF_HEADER_NAME = new InjectionToken('XSRF_HEADER_NAME'); + +/** + * Retrieves the current XSRF token to use with the next outgoing request. + * + * @experimental + */ +export abstract class HttpXsrfTokenExtractor { + /** + * Get the XSRF token to use with an outgoing request. + * + * Will be called for every request, so the token may change between requests. + */ + abstract getToken(): string|null; +} + +/** + * `HttpXsrfTokenExtractor` which retrieves the token from a cookie. + */ +@Injectable() +export class HttpXsrfCookieExtractor implements HttpXsrfTokenExtractor { + private lastCookieString: string = ''; + private lastToken: string|null = null; + + /** + * @internal for testing + */ + parseCount: number = 0; + + constructor( + @Inject(DOCUMENT) private doc: any, @Inject(PLATFORM_ID) private platform: string, + @Inject(XSRF_COOKIE_NAME) private cookieName: string) {} + + getToken(): string|null { + if (this.platform === 'server') { + return null; + } + const cookieString = this.doc.cookie || ''; + if (cookieString !== this.lastCookieString) { + this.parseCount++; + this.lastToken = parseCookieValue(cookieString, this.cookieName); + this.lastCookieString = cookieString; + } + return this.lastToken; + } +} + +/** + * `HttpInterceptor` which adds an XSRF token to eligible outgoing requests. + */ +@Injectable() +export class HttpXsrfInterceptor implements HttpInterceptor { + constructor( + private tokenService: HttpXsrfTokenExtractor, + @Inject(XSRF_HEADER_NAME) private headerName: string) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const lcUrl = req.url.toLowerCase(); + // Skip both non-mutating requests and absolute URLs. + // Non-mutating requests don't require a token, and absolute URLs require special handling + // anyway as the cookie set + // on our origin is not the same as the token expected by another origin. + if (req.method === 'GET' || req.method === 'HEAD' || lcUrl.startsWith('http://') || + lcUrl.startsWith('https://')) { + return next.handle(req); + } + const token = this.tokenService.getToken(); + + // Be careful not to overwrite an existing header of the same name. + if (token !== null && !req.headers.has(this.headerName)) { + req = req.clone({headers: req.headers.set(this.headerName, token)}); + } + return next.handle(req); + } +} diff --git a/packages/common/http/test/xsrf_spec.ts b/packages/common/http/test/xsrf_spec.ts new file mode 100644 index 0000000000..f59b7df84d --- /dev/null +++ b/packages/common/http/test/xsrf_spec.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {HttpHandler} from '../src/backend'; +import {HttpHeaders} from '../src/headers'; +import {HttpRequest} from '../src/request'; +import {HttpXsrfCookieExtractor, HttpXsrfInterceptor} from '../src/xsrf'; + +import {HttpClientTestingBackend} from '../testing/src/backend'; + +class SampleTokenExtractor { + constructor(private token: string|null) {} + + getToken(): string|null { return this.token; } +} + +export function main() { + describe('HttpXsrfInterceptor', () => { + let backend: HttpClientTestingBackend; + const interceptor = new HttpXsrfInterceptor(new SampleTokenExtractor('test'), 'X-XSRF-TOKEN'); + beforeEach(() => { backend = new HttpClientTestingBackend(); }); + it('applies XSRF protection to outgoing requests', () => { + interceptor.intercept(new HttpRequest('POST', '/test', {}), backend).subscribe(); + const req = backend.expectOne('/test'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('test'); + req.flush({}); + }); + it('does not apply XSRF protection when request is a GET', () => { + interceptor.intercept(new HttpRequest('GET', '/test'), backend).subscribe(); + const req = backend.expectOne('/test'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toEqual(false); + req.flush({}); + }); + it('does not apply XSRF protection when request is a HEAD', () => { + interceptor.intercept(new HttpRequest('HEAD', '/test'), backend).subscribe(); + const req = backend.expectOne('/test'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toEqual(false); + req.flush({}); + }); + it('does not overwrite existing header', () => { + interceptor + .intercept( + new HttpRequest( + 'POST', '/test', {}, {headers: new HttpHeaders().set('X-XSRF-TOKEN', 'blah')}), + backend) + .subscribe(); + const req = backend.expectOne('/test'); + expect(req.request.headers.get('X-XSRF-TOKEN')).toEqual('blah'); + req.flush({}); + }); + it('does not set the header for a null token', () => { + const interceptor = new HttpXsrfInterceptor(new SampleTokenExtractor(null), 'X-XSRF-TOKEN'); + interceptor.intercept(new HttpRequest('POST', '/test', {}), backend).subscribe(); + const req = backend.expectOne('/test'); + expect(req.request.headers.has('X-XSRF-TOKEN')).toEqual(false); + req.flush({}); + }); + afterEach(() => { backend.verify(); }); + }); + describe('HttpXsrfCookieExtractor', () => { + let document: {[key: string]: string}; + let extractor: HttpXsrfCookieExtractor + beforeEach(() => { + document = { + cookie: 'XSRF-TOKEN=test', + }; + extractor = new HttpXsrfCookieExtractor(document, 'browser', 'XSRF-TOKEN'); + }); + it('parses the cookie from document.cookie', + () => { expect(extractor.getToken()).toEqual('test'); }); + it('does not re-parse if document.cookie has not changed', () => { + expect(extractor.getToken()).toEqual('test'); + expect(extractor.getToken()).toEqual('test'); + expect(extractor.parseCount).toEqual(1); + }); + it('re-parses if document.cookie changes', () => { + expect(extractor.getToken()).toEqual('test'); + document['cookie'] = 'XSRF-TOKEN=blah'; + expect(extractor.getToken()).toEqual('blah'); + expect(extractor.parseCount).toEqual(2); + }); + }); +} diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 0c3e04abde..c4e7d3cb76 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -13,6 +13,7 @@ */ export * from './location/index'; export {NgLocaleLocalization, NgLocalization} from './localization'; +export {parseCookieValue as ɵparseCookieValue} from './cookie'; export {CommonModule} from './common_module'; export {NgClass, NgFor, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index'; export {DOCUMENT} from './dom_tokens'; diff --git a/packages/common/src/cookie.ts b/packages/common/src/cookie.ts new file mode 100644 index 0000000000..1e577cfe2b --- /dev/null +++ b/packages/common/src/cookie.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export function parseCookieValue(cookieStr: string, name: string): string|null { + name = encodeURIComponent(name); + for (const cookie of cookieStr.split(';')) { + const eqIndex = cookie.indexOf('='); + const [cookieName, cookieValue]: string[] = + eqIndex == -1 ? [cookie, ''] : [cookie.slice(0, eqIndex), cookie.slice(eqIndex + 1)]; + if (cookieName.trim() === name) { + return decodeURIComponent(cookieValue); + } + } + return null; +} diff --git a/packages/common/test/cookie_spec.ts b/packages/common/test/cookie_spec.ts new file mode 100644 index 0000000000..e0a8eeefa4 --- /dev/null +++ b/packages/common/test/cookie_spec.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + + +/** +* @license +* Copyright Google Inc. All Rights Reserved. +* +* Use of this source code is governed by an MIT-style license that can be +* found in the LICENSE file at https://angular.io/license +*/ + +import {parseCookieValue} from '@angular/common/src/cookie'; +import {describe, expect, it} from '@angular/core/testing/src/testing_internal'; + +export function main() { + describe('cookies', () => { + it('parses cookies', () => { + const 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'); + }); + }); +} diff --git a/packages/platform-browser/src/browser/browser_adapter.ts b/packages/platform-browser/src/browser/browser_adapter.ts index 63e2b65d62..89dafbd21d 100644 --- a/packages/platform-browser/src/browser/browser_adapter.ts +++ b/packages/platform-browser/src/browser/browser_adapter.ts @@ -6,7 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ +import {ɵparseCookieValue as parseCookieValue} from '@angular/common'; import {ɵglobal as global} from '@angular/core'; + import {setRootDomAdapter} from '../dom/dom_adapter'; import {GenericBrowserDomAdapter} from './generic_browser_adapter'; @@ -405,16 +407,3 @@ function relativePath(url: any): string { return (urlParsingNode.pathname.charAt(0) === '/') ? urlParsingNode.pathname : '/' + urlParsingNode.pathname; } - -export function parseCookieValue(cookieStr: string, name: string): string|null { - name = encodeURIComponent(name); - for (const cookie of cookieStr.split(';')) { - const eqIndex = cookie.indexOf('='); - const [cookieName, cookieValue]: string[] = - eqIndex == -1 ? [cookie, ''] : [cookie.slice(0, eqIndex), cookie.slice(eqIndex + 1)]; - if (cookieName.trim() === name) { - return decodeURIComponent(cookieValue); - } - } - return null; -} diff --git a/packages/platform-browser/test/browser/browser_adapter_spec.ts b/packages/platform-browser/test/browser/browser_adapter_spec.ts index 38a14ac73c..5230ed926e 100644 --- a/packages/platform-browser/test/browser/browser_adapter_spec.ts +++ b/packages/platform-browser/test/browser/browser_adapter_spec.ts @@ -9,22 +9,9 @@ import {describe, expect, it} from '@angular/core/testing/src/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', () => { - const 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'); diff --git a/tools/public_api_guard/common/http.d.ts b/tools/public_api_guard/common/http.d.ts index 49b61581e0..bf1dc4a0b2 100644 --- a/tools/public_api_guard/common/http.d.ts +++ b/tools/public_api_guard/common/http.d.ts @@ -1246,6 +1246,20 @@ export declare class HttpXhrBackend implements HttpBackend { handle(req: HttpRequest): Observable>; } +/** @experimental */ +export declare class HttpXsrfModule { + static disable(): ModuleWithProviders; + static withOptions(options?: { + cookieName?: string; + headerName?: string; + }): ModuleWithProviders; +} + +/** @experimental */ +export declare abstract class HttpXsrfTokenExtractor { + abstract getToken(): string | null; +} + /** @experimental */ export declare class JsonpClientBackend implements HttpBackend { constructor(callbackMap: JsonpCallbackContext, document: any);