Skip to content

Commit 86ddd45

Browse files
authored
Merge pull request DSpace#2886 from tdonohue/csrf_fixes
Ensures CSRF token is initialized prior to first modifying (non-`GET`) request
2 parents b3b3ef8 + bbab56a commit 86ddd45

33 files changed

Lines changed: 269 additions & 7 deletions

File tree

.github/workflows/build.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
#CHROME_VERSION: "90.0.4430.212-1"
3434
# Bump Node heap size (OOM in CI after upgrading to Angular 15)
3535
NODE_OPTIONS: '--max-old-space-size=4096'
36-
# Project name to use when running docker-compose prior to e2e tests
36+
# Project name to use when running "docker compose" prior to e2e tests
3737
COMPOSE_PROJECT_NAME: 'ci'
3838
strategy:
3939
# Create a matrix of Node versions to test against (in parallel)
@@ -108,12 +108,12 @@ jobs:
108108
path: 'coverage/dspace-angular/lcov.info'
109109
retention-days: 14
110110

111-
# Using docker-compose start backend using CI configuration
111+
# Using "docker compose" start backend using CI configuration
112112
# and load assetstore from a cached copy
113113
- name: Start DSpace REST Backend via Docker (for e2e tests)
114114
run: |
115-
docker-compose -f ./docker/docker-compose-ci.yml up -d
116-
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
115+
docker compose -f ./docker/docker-compose-ci.yml up -d
116+
docker compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
117117
docker container ls
118118
119119
# Run integration tests via Cypress.io
@@ -182,7 +182,7 @@ jobs:
182182
run: kill -9 $(lsof -t -i:4000)
183183

184184
- name: Shutdown Docker containers
185-
run: docker-compose -f ./docker/docker-compose-ci.yml down
185+
run: docker compose -f ./docker/docker-compose-ci.yml down
186186

187187
# Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
188188
# job above. This is necessary because Codecov uploads seem to randomly fail at times.

src/app/access-control/group-registry/group-form/group-form.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
5353
import { NoContent } from '../../../core/shared/NoContent.model';
5454
import { PageInfo } from '../../../core/shared/page-info.model';
5555
import { UUIDService } from '../../../core/shared/uuid.service';
56+
import { XSRFService } from '../../../core/xsrf/xsrf.service';
5657
import { AlertComponent } from '../../../shared/alert/alert.component';
5758
import { ContextHelpDirective } from '../../../shared/context-help.directive';
5859
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
@@ -244,6 +245,7 @@ describe('GroupFormComponent', () => {
244245
{ provide: HttpClient, useValue: {} },
245246
{ provide: ObjectCacheService, useValue: {} },
246247
{ provide: UUIDService, useValue: {} },
248+
{ provide: XSRFService, useValue: {} },
247249
{ provide: Store, useValue: {} },
248250
{ provide: RemoteDataBuildService, useValue: {} },
249251
{ provide: HALEndpointService, useValue: {} },

src/app/admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { GroupDataService } from '../../../core/eperson/group-data.service';
3131
import { PaginationService } from '../../../core/pagination/pagination.service';
3232
import { BitstreamFormat } from '../../../core/shared/bitstream-format.model';
3333
import { BitstreamFormatSupportLevel } from '../../../core/shared/bitstream-format-support-level';
34+
import { XSRFService } from '../../../core/xsrf/xsrf.service';
3435
import { HostWindowService } from '../../../shared/host-window.service';
3536
import { NotificationsService } from '../../../shared/notifications/notifications.service';
3637
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
@@ -143,6 +144,7 @@ describe('BitstreamFormatsComponent', () => {
143144
{ provide: PaginationService, useValue: paginationService },
144145
{ provide: GroupDataService, useValue: groupDataService },
145146
{ provide: ConfigurationDataService, useValue: configurationDataService },
147+
{ provide: XSRFService, useValue: {} },
146148
],
147149
schemas: [NO_ERRORS_SCHEMA],
148150
}).compileComponents();

src/app/admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AuthorizationDataService } from '../../../../../core/data/feature-autho
1717
import { Item } from '../../../../../core/shared/item.model';
1818
import { ViewMode } from '../../../../../core/shared/view-mode.model';
1919
import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model';
20+
import { XSRFService } from '../../../../../core/xsrf/xsrf.service';
2021
import { AuthServiceMock } from '../../../../../shared/mocks/auth.service.mock';
2122
import { DSONameServiceMock } from '../../../../../shared/mocks/dso-name.service.mock';
2223
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
@@ -67,6 +68,7 @@ describe('WorkflowItemSearchResultAdminWorkflowListElementComponent', () => {
6768
{ provide: ThemeService, useValue: getMockThemeService() },
6869
{ provide: AuthService, useValue: new AuthServiceMock() },
6970
{ provide: AuthorizationDataService, useValue: {} },
71+
{ provide: XSRFService, useValue: {} },
7072
],
7173
schemas: [NO_ERRORS_SCHEMA],
7274
})

src/app/core/data/request.service.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
getTestScheduler,
1717
} from 'jasmine-marbles';
1818
import {
19+
BehaviorSubject,
1920
EMPTY,
2021
Observable,
2122
of as observableOf,
@@ -32,6 +33,7 @@ import { ObjectCacheService } from '../cache/object-cache.service';
3233
import { coreReducers } from '../core.reducers';
3334
import { CoreState } from '../core-state.model';
3435
import { UUIDService } from '../shared/uuid.service';
36+
import { XSRFService } from '../xsrf/xsrf.service';
3537
import {
3638
RequestConfigureAction,
3739
RequestExecuteAction,
@@ -59,6 +61,7 @@ describe('RequestService', () => {
5961
let uuidService: UUIDService;
6062
let store: Store<CoreState>;
6163
let mockStore: MockStore<CoreState>;
64+
let xsrfService: XSRFService;
6265

6366
const testUUID = '5f2a0d2a-effa-4d54-bd54-5663b960f9eb';
6467
const testHref = 'https://rest.api/endpoint/selfLink';
@@ -104,10 +107,15 @@ describe('RequestService', () => {
104107
store = TestBed.inject(Store);
105108
mockStore = store as MockStore<CoreState>;
106109
mockStore.setState(initialState);
110+
xsrfService = {
111+
tokenInitialized$: new BehaviorSubject(false),
112+
} as XSRFService;
113+
107114
service = new RequestService(
108115
objectCache,
109116
uuidService,
110117
store,
118+
xsrfService,
111119
undefined,
112120
);
113121
serviceAsAny = service as any;

src/app/core/data/request.service.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
requestIndexSelector,
4343
} from '../index/index.selectors';
4444
import { UUIDService } from '../shared/uuid.service';
45+
import { XSRFService } from '../xsrf/xsrf.service';
4546
import {
4647
RequestConfigureAction,
4748
RequestExecuteAction,
@@ -168,6 +169,7 @@ export class RequestService {
168169
constructor(private objectCache: ObjectCacheService,
169170
private uuidService: UUIDService,
170171
private store: Store<CoreState>,
172+
protected xsrfService: XSRFService,
171173
private indexStore: Store<MetaIndexState>) {
172174
}
173175

@@ -450,7 +452,17 @@ export class RequestService {
450452
*/
451453
private dispatchRequest(request: RestRequest) {
452454
this.store.dispatch(new RequestConfigureAction(request));
453-
this.store.dispatch(new RequestExecuteAction(request.uuid));
455+
// If it's a GET request, or we have an XSRF token, dispatch it immediately
456+
if (request.method === RestRequestMethod.GET || this.xsrfService.tokenInitialized$.getValue() === true) {
457+
this.store.dispatch(new RequestExecuteAction(request.uuid));
458+
} else {
459+
// Otherwise wait for the XSRF token first
460+
this.xsrfService.tokenInitialized$.pipe(
461+
find((hasInitialized: boolean) => hasInitialized === true),
462+
).subscribe(() => {
463+
this.store.dispatch(new RequestExecuteAction(request.uuid));
464+
});
465+
}
454466
}
455467

456468
/**
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import {
3+
HttpClientTestingModule,
4+
HttpTestingController,
5+
} from '@angular/common/http/testing';
6+
import { TestBed } from '@angular/core/testing';
7+
8+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
9+
import { BrowserXSRFService } from './browser-xsrf.service';
10+
11+
describe(`BrowserXSRFService`, () => {
12+
let service: BrowserXSRFService;
13+
let httpClient: HttpClient;
14+
let httpTestingController: HttpTestingController;
15+
16+
const endpointURL = new RESTURLCombiner('/security/csrf').toString();
17+
18+
beforeEach(() => {
19+
TestBed.configureTestingModule({
20+
imports: [ HttpClientTestingModule ],
21+
providers: [ BrowserXSRFService ],
22+
});
23+
httpClient = TestBed.inject(HttpClient);
24+
httpTestingController = TestBed.inject(HttpTestingController);
25+
service = TestBed.inject(BrowserXSRFService);
26+
});
27+
28+
describe(`initXSRFToken`, () => {
29+
it(`should perform a GET to the csrf endpoint`, (done: DoneFn) => {
30+
service.initXSRFToken(httpClient)();
31+
32+
const req = httpTestingController.expectOne({
33+
url: endpointURL,
34+
method: 'GET',
35+
});
36+
37+
req.flush({});
38+
httpTestingController.verify();
39+
expect().nothing();
40+
done();
41+
});
42+
43+
describe(`when the GET succeeds`, () => {
44+
it(`should set tokenInitialized$ to true`, (done: DoneFn) => {
45+
service.initXSRFToken(httpClient)();
46+
47+
const req = httpTestingController.expectOne(endpointURL);
48+
49+
req.flush({});
50+
httpTestingController.verify();
51+
52+
expect(service.tokenInitialized$.getValue()).toBeTrue();
53+
done();
54+
});
55+
});
56+
57+
});
58+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
import { take } from 'rxjs/operators';
4+
5+
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
6+
import { XSRFService } from './xsrf.service';
7+
8+
/**
9+
* Browser (CSR) Service to obtain a new CSRF/XSRF token when needed by our RequestService
10+
* to perform a modify request (e.g. POST/PUT/DELETE).
11+
* NOTE: This is primarily necessary before the *first* modifying request, as the CSRF
12+
* token may not yet be initialized.
13+
*/
14+
@Injectable()
15+
export class BrowserXSRFService extends XSRFService {
16+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
17+
return () => new Promise<void>((resolve) => {
18+
// Force a new token to be created by calling the CSRF endpoint
19+
httpClient.get(new RESTURLCombiner('/security/csrf').toString(), undefined).pipe(
20+
take(1),
21+
).subscribe(() => {
22+
// Once token is returned, set tokenInitialized to true.
23+
this.tokenInitialized$.next(true);
24+
});
25+
26+
// return immediately, the rest of the app doesn't need to wait for this to finish
27+
resolve();
28+
});
29+
}
30+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { HttpClient } from '@angular/common/http';
2+
3+
import { ServerXSRFService } from './server-xsrf.service';
4+
5+
describe(`ServerXSRFService`, () => {
6+
let service: ServerXSRFService;
7+
let httpClient: HttpClient;
8+
9+
beforeEach(() => {
10+
httpClient = jasmine.createSpyObj(['post', 'get', 'request']);
11+
service = new ServerXSRFService();
12+
});
13+
14+
describe(`initXSRFToken`, () => {
15+
it(`shouldn't perform any requests`, (done: DoneFn) => {
16+
service.initXSRFToken(httpClient)().then(() => {
17+
for (const prop in httpClient) {
18+
if (httpClient.hasOwnProperty(prop)) {
19+
expect(httpClient[prop]).not.toHaveBeenCalled();
20+
}
21+
}
22+
done();
23+
});
24+
});
25+
26+
it(`should leave tokenInitialized$ on false`, (done: DoneFn) => {
27+
service.initXSRFToken(httpClient)().then(() => {
28+
expect(service.tokenInitialized$.getValue()).toBeFalse();
29+
done();
30+
});
31+
});
32+
});
33+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { HttpClient } from '@angular/common/http';
2+
import { Injectable } from '@angular/core';
3+
4+
import { XSRFService } from './xsrf.service';
5+
6+
/**
7+
* Server (SSR) Service to obtain a new CSRF/XSRF token. Because SSR only triggers GET
8+
* requests a CSRF token is never needed.
9+
*/
10+
@Injectable()
11+
export class ServerXSRFService extends XSRFService {
12+
initXSRFToken(httpClient: HttpClient): () => Promise<any> {
13+
return () => new Promise<void>((resolve) => {
14+
// return immediately, and keep tokenInitialized$ false. The server side can make only GET
15+
// requests, since it can never get a valid XSRF cookie
16+
resolve();
17+
});
18+
}
19+
}

0 commit comments

Comments
 (0)