diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d8ab8eb687..be1233fd98 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -42,6 +42,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { ForbiddenComponent } from './forbidden/forbidden.component'; import { AuthInterceptor } from './core/auth/auth.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor'; +import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; export function getBase() { return environment.ui.nameSpace; @@ -108,6 +109,12 @@ const PROVIDERS = [ useClass: LocaleInterceptor, multi: true }, + // register XsrfInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: XsrfInterceptor, + multi: true + }, ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/core/xsrf/xsrf.interceptor.spec.ts b/src/app/core/xsrf/xsrf.interceptor.spec.ts new file mode 100644 index 0000000000..007286302f --- /dev/null +++ b/src/app/core/xsrf/xsrf.interceptor.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpHeaders, HTTP_INTERCEPTORS, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http'; +import { DspaceRestService } from '../dspace-rest/dspace-rest.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { CookieService } from '../services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { XsrfInterceptor } from './xsrf.interceptor'; + +/** + * A Mock TokenExtractor which just returns whatever token it is initialized with. + * This mock object is injected into our XsrfInterceptor, so that it always finds + * the same fake XSRF token. + */ +class MockTokenExtractor extends HttpXsrfTokenExtractor { + constructor(private token: string | null) { super(); } + + getToken(): string | null { return this.token; } +} + +describe(`XsrfInterceptor`, () => { + let service: DspaceRestService; + let httpMock: HttpTestingController; + let cookieService: CookieService; + + // Create a MockTokenExtractor which always returns "test-token". This will + // be used as the test HttpXsrfTokenExtractor, see below. + const testToken = 'test-token'; + const mockTokenExtractor = new MockTokenExtractor(testToken); + + // Mock payload/statuses are dummy content as we are not testing the results + // of any below requests. We are only testing for X-XSRF-TOKEN header. + const mockPayload = { + id: 1 + }; + const mockStatusCode = 200; + const mockStatusText = 'SUCCESS'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DspaceRestService, + { + provide: HTTP_INTERCEPTORS, + useClass: XsrfInterceptor, + multi: true, + }, + { provide: HttpXsrfTokenExtractor, useValue: mockTokenExtractor }, + { provide: CookieService, useValue: new CookieServiceMock() } + ], + }); + + service = TestBed.get(DspaceRestService); + httpMock = TestBed.get(HttpTestingController); + cookieService = TestBed.get(CookieService); + }); + + it('should change withCredentials to true at all times', (done) => { + service.request(RestRequestMethod.POST, 'server/api/core/items', 'test', { withCredentials: false }).subscribe((response) => { + expect(response).toBeTruthy(); + done(); + }); + + const httpRequest = httpMock.expectOne('server/api/core/items'); + expect(httpRequest.request.withCredentials).toBeTrue(); + + httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); + }); + + it('should add an X-XSRF-TOKEN header when we are sending an HTTP POST request', (done) => { + service.request(RestRequestMethod.POST, 'server/api/core/items', 'test').subscribe((response) => { + expect(response).toBeTruthy(); + done(); + }); + + const httpRequest = httpMock.expectOne('server/api/core/items'); + + expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeTrue(); + expect(httpRequest.request.withCredentials).toBeTrue(); + const token = httpRequest.request.headers.get('X-XSRF-TOKEN'); + expect(token).toBeDefined(); + expect(token).toBe(testToken.toString()); + + httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); + }); + + it('should NOT add an X-XSRF-TOKEN header when we are sending an HTTP GET request', (done) => { + service.request(RestRequestMethod.GET, 'server/api/core/items').subscribe((response) => { + expect(response).toBeTruthy(); + done(); + }); + + const httpRequest = httpMock.expectOne('server/api/core/items'); + + expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeFalse(); + expect(httpRequest.request.withCredentials).toBeTrue(); + + httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); + }); + + it('should NOT add an X-XSRF-TOKEN header when we are sending an HTTP POST to an untrusted URL', (done) => { + // POST to a URL which is NOT our REST API + service.request(RestRequestMethod.POST, 'https://untrusted.com', 'test').subscribe((response) => { + expect(response).toBeTruthy(); + done(); + }); + + const httpRequest = httpMock.expectOne('https://untrusted.com'); + + expect(httpRequest.request.headers.has('X-XSRF-TOKEN')).toBeFalse(); + expect(httpRequest.request.withCredentials).toBeTrue(); + + httpRequest.flush(mockPayload, { status: mockStatusCode, statusText: mockStatusText }); + }); + + it('should update XSRF-TOKEN cookie when DSPACE-XSRF-TOKEN header found in response', (done) => { + // Create a mock XSRF token to be returned in response within DSPACE-XSRF-TOKEN header + const mockNewXSRFToken = '123456789abcdefg'; + + service.request(RestRequestMethod.GET, 'server/api/core/items').subscribe((response) => { + expect(response).toBeTruthy(); + + // ensure mock data (added in below flush() call) is returned. + expect(response.statusCode).toBe(mockStatusCode); + expect(response.statusText).toBe(mockStatusText); + + // ensure mock XSRF token is in response + expect(response.headers.has('DSPACE-XSRF-TOKEN')).toBeTrue(); + const token = response.headers.get('DSPACE-XSRF-TOKEN'); + expect(token).toBeDefined(); + expect(token).toBe(mockNewXSRFToken.toString()); + + // ensure our XSRF-TOKEN cookie exists & has the same value as the new DSPACE-XSRF-TOKEN header + expect(cookieService.get('XSRF-TOKEN')).toBeDefined(); + expect(cookieService.get('XSRF-TOKEN')).toBe(mockNewXSRFToken.toString()); + + done(); + }); + + const httpRequest = httpMock.expectOne('server/api/core/items'); + + // Flush & create mock response (including sending back a new XSRF token in header) + httpRequest.flush(mockPayload, { + headers: new HttpHeaders().set('DSPACE-XSRF-TOKEN', mockNewXSRFToken), + status: mockStatusCode, + statusText: mockStatusText + }); + }); + +}); diff --git a/src/app/core/xsrf/xsrf.interceptor.ts b/src/app/core/xsrf/xsrf.interceptor.ts new file mode 100644 index 0000000000..ceb401e4c4 --- /dev/null +++ b/src/app/core/xsrf/xsrf.interceptor.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse, HttpXsrfTokenExtractor } from '@angular/common/http'; +import { Observable } from 'rxjs/internal/Observable'; +import { tap, filter } from 'rxjs/operators'; +import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; +import { CookieService } from '../services/cookie.service'; + +/** + * Custom Http Interceptor intercepting Http Requests & Responses to + * exchange XSRF/CSRF tokens with the backend. + * + * DSpace has a custom XSRF token process in order to support the UI and backend + * running on entirely separate domains: + * + * 1. Backend generates XSRF token & stores in a *server-side* (only) cookie + * named DSPACE-XSRF-COOKIE. This cookie is not readable to Angular, but is + * returned (by user's browser) on every subsequent request to backend. + * 2. Backend also sends XSRF token in a header named DSPACE-XSRF-TOKEN to + * Angular. + * 3. This interceptor looks for DSPACE-XSRF-TOKEN header in a response. If + * found, its value is saved to a *client-side* (only) cookie named XSRF-TOKEN. + * 4. Whenever Angular is making a mutating request (POST, PUT, DELETE, etc), + * this interceptor checks for that client-side XSRF-TOKEN cookie. If found, + * its value is sent to the backend in the X-XSRF-TOKEN header. + * 5. On backend, the X-XSRF-TOKEN header is received & compared to the current + * value of the *server-side* cookie named DSPACE-XSRF-COOKIE. If tokens + * match, the request is accepted. If tokens don't match a 403 is returned. + * + * In summary, the XSRF token is ALWAYS sent to/from the Angular UI and backend + * via *headers*. Both the Angular UI and backend have cookies which retain the + * last known value of the XSRF token for verification purposes. + */ +@Injectable() +export class XsrfInterceptor implements HttpInterceptor { + + constructor(private tokenExtractor: HttpXsrfTokenExtractor, private cookieService: CookieService) { + } + + /** + * Intercept http requests and add the XSRF/CSRF token to the X-Forwarded-For header + * @param httpRequest + * @param next + */ + intercept(req: HttpRequest, next: HttpHandler): Observable> { + // Ensure EVERY request from Angular includes "withCredentials: true". + // This allows Angular to receive & send cookies via a CORS request (to + // the backend). ONLY requests with credentials will: + // 1. Ensure a user's browser sends the server-side XSRF cookie back to + // the backend + // 2. Ensure a user's browser saves changes to the server-side XSRF + // cookie (to ensure it is kept in sync with client side cookie) + req = req.clone({ withCredentials: true }); + + // Get request URL + const reqUrl = req.url.toLowerCase(); + + // Get root URL of configured REST API + const restUrl = new RESTURLCombiner('/').toString().toLowerCase(); + + // Skip any non-mutating request. This is because our REST API does NOT + // require CSRF verification for read-only requests like GET or HEAD + // Also skip any request which is NOT to our trusted/configured REST API + if (req.method !== 'GET' && req.method !== 'HEAD' && reqUrl.startsWith(restUrl)) { + // parse token from XSRF-TOKEN (client-side) cookie + const token = this.tokenExtractor.getToken() as string; + + // send token in request's X-XSRF-TOKEN header (anti-CSRF security) to backend + const headerName = 'X-XSRF-TOKEN'; + if (token !== null && !req.headers.has(headerName)) { + req = req.clone({ headers: req.headers.set(headerName, token) }); + } + } + // Pass to next interceptor, but intercept EVERY response event as well + return next.handle(req).pipe( + // Check event that came back...is it an HttpResponse from backend? + filter((event) => event instanceof HttpResponse), + tap((response: HttpResponse) => { + // For every response that comes back, check for the custom + // DSPACE-XSRF-TOKEN header sent from the backend. + if (response.headers.has('DSPACE-XSRF-TOKEN')) { + // value of header is a new XSRF token + const newToken = response.headers.get('DSPACE-XSRF-TOKEN'); + + // save token value as a *new* value of our client-side + // XSRF-TOKEN cookie. (This is the same cookie we use to + // send back the X-XSRF-TOKEN header. See request logic above) + this.cookieService.remove('XSRF-TOKEN'); + this.cookieService.set('XSRF-TOKEN', newToken); + } + }) + ) as any; + } +}