Skip to content

Commit 48fad29

Browse files
committed
fix: enable csrf protection
1 parent c647290 commit 48fad29

6 files changed

Lines changed: 55 additions & 11 deletions

File tree

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/SecurityConfiguration.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1111
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1212
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
13-
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
1413
import org.springframework.security.config.http.SessionCreationPolicy;
1514
import org.springframework.security.web.SecurityFilterChain;
1615
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@@ -36,7 +35,7 @@ public SecurityFilterChain securityFilterChain(
3635
CognitoGroupsJwtAuthenticationConverter cognitoGroupsConverter
3736
) throws Exception {
3837
http
39-
.csrf(AbstractHttpConfigurer::disable)
38+
.csrf(csrf -> csrf.spa())
4039
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
4140
.exceptionHandling(exceptions -> exceptions
4241
.authenticationEntryPoint((request, response, exception) ->

backend/src/test/java/ca/bc/gov/nrs/ilcr/configuration/SecurityConfigurationTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package ca.bc.gov.nrs.ilcr.configuration;
22

3+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
34
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
45
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
6+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
7+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie;
58
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
69

710
import org.junit.jupiter.api.BeforeEach;
@@ -51,4 +54,28 @@ void securedApiPath_requiresJwt() throws Exception {
5154
.param("year", "2021"))
5255
.andExpect(status().isUnauthorized());
5356
}
57+
58+
@Test
59+
void apiReads_issueCsrfTokenCookieForSpaWrites() throws Exception {
60+
mockMvc.perform(get("/api/health/readiness"))
61+
.andExpect(status().isOk())
62+
.andExpect(cookie().exists("XSRF-TOKEN"));
63+
}
64+
65+
@Test
66+
void unsafeApiPath_withoutCsrfToken_isForbidden() throws Exception {
67+
mockMvc.perform(put("/api/v1/schedule1")
68+
.param("millId", "518")
69+
.param("year", "2021"))
70+
.andExpect(status().isForbidden());
71+
}
72+
73+
@Test
74+
void unsafeApiPath_withCsrfToken_stillRequiresJwt() throws Exception {
75+
mockMvc.perform(put("/api/v1/schedule1")
76+
.param("millId", "518")
77+
.param("year", "2021")
78+
.with(csrf()))
79+
.andExpect(status().isUnauthorized());
80+
}
5481
}

backend/src/test/java/ca/bc/gov/nrs/ilcr/schedule1/Schedule1WriteAuthorizationIT.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ca.bc.gov.nrs.ilcr.schedule1;
22

3+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
34
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
45
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
56
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -63,6 +64,7 @@ void put_noPermission_returns403() throws Exception {
6364
mockMvc.perform(put(ENDPOINT)
6465
.param("millId", "518").param("year", "2021")
6566
.contentType(MediaType.APPLICATION_JSON).content(BODY)
67+
.with(csrf())
6668
.with(jwtWithGroups(List.of())))
6769
.andExpect(status().isForbidden())
6870
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
@@ -73,6 +75,7 @@ void put_noPermission_returns403() throws Exception {
7375
void delete_noPermission_returns403() throws Exception {
7476
mockMvc.perform(delete(ENDPOINT)
7577
.param("millId", "519").param("year", "2021")
78+
.with(csrf())
7679
.with(jwtWithGroups(List.of())))
7780
.andExpect(status().isForbidden())
7881
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
@@ -86,6 +89,7 @@ void put_submitter_passesAuthorization() throws Exception {
8689
mockMvc.perform(put(ENDPOINT)
8790
.param("millId", "521").param("year", "2021")
8891
.contentType(MediaType.APPLICATION_JSON).content(BODY)
92+
.with(csrf())
8993
.with(jwtWithGroups(List.of("ILCR_SUBMITTER"))))
9094
.andExpect(status().is2xxSuccessful());
9195
}

backend/src/test/java/ca/bc/gov/nrs/ilcr/schedule1/Schedule1WriteFailureIT.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static org.mockito.ArgumentMatchers.eq;
99
import static org.mockito.Mockito.doThrow;
1010
import static org.mockito.Mockito.reset;
11+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
1112
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
1213
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
1314
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -67,7 +68,8 @@ void saveFailure_rollsBackAnd500_thenRetrySucceeds() throws Exception {
6768
.when(repository).upsertFixedDetail(anyInt(), eq(12), any(), any(), anyString());
6869

6970
mockMvc.perform(put(ENDPOINT).param("millId", "520").param("year", "2021")
70-
.contentType(MediaType.APPLICATION_JSON).content(body(before)))
71+
.contentType(MediaType.APPLICATION_JSON).content(body(before))
72+
.with(csrf()))
7173
.andExpect(status().isInternalServerError())
7274
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
7375
.andExpect(jsonPath("$.detail", is("Schedule could not be saved.")));
@@ -78,7 +80,8 @@ void saveFailure_rollsBackAnd500_thenRetrySucceeds() throws Exception {
7880
// Fault clears; the identical retry with the same (still-current) token succeeds (S24).
7981
reset(repository);
8082
mockMvc.perform(put(ENDPOINT).param("millId", "520").param("year", "2021")
81-
.contentType(MediaType.APPLICATION_JSON).content(body(before)))
83+
.contentType(MediaType.APPLICATION_JSON).content(body(before))
84+
.with(csrf()))
8285
.andExpect(status().isOk())
8386
.andExpect(jsonPath("$.revisionCount", is(before + 1)));
8487
}

backend/src/test/java/ca/bc/gov/nrs/ilcr/schedule1/Schedule1WriteIT.java

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.hamcrest.Matchers.greaterThan;
55
import static org.hamcrest.Matchers.is;
66
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
78
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
89
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
910
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -105,6 +106,7 @@ void put_persistsRecomputesBumpsRevision_ignoresDerived_leavesItemizedRows() thr
105106
mockMvc.perform(put(ENDPOINT)
106107
.param("millId", "518").param("year", "2021")
107108
.contentType(MediaType.APPLICATION_JSON).content(body)
109+
.with(csrf())
108110
.accept(MediaType.APPLICATION_JSON))
109111
.andExpect(status().isOk())
110112
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
@@ -203,7 +205,8 @@ private void expect400NothingPersisted(String body, String verbatimDetail) throw
203205
int before = revisionOf(1018);
204206
mockMvc.perform(put(ENDPOINT)
205207
.param("millId", "518").param("year", "2021")
206-
.contentType(MediaType.APPLICATION_JSON).content(body))
208+
.contentType(MediaType.APPLICATION_JSON).content(body)
209+
.with(csrf()))
207210
.andExpect(status().isBadRequest())
208211
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
209212
.andExpect(jsonPath("$.detail", is(verbatimDetail)));
@@ -218,7 +221,8 @@ void put_nonDraft_returns409() throws Exception {
218221
int before = revisionOf(1017);
219222
mockMvc.perform(put(ENDPOINT)
220223
.param("millId", "517").param("year", "2021")
221-
.contentType(MediaType.APPLICATION_JSON).content(validBody(before)))
224+
.contentType(MediaType.APPLICATION_JSON).content(validBody(before))
225+
.with(csrf()))
222226
.andExpect(status().isConflict())
223227
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
224228
.andExpect(jsonPath("$.detail", is("This schedule cannot be edited in its current status.")));
@@ -228,7 +232,8 @@ void put_nonDraft_returns409() throws Exception {
228232
@Test
229233
@DisplayName("S22-write — DELETE against non-Draft (mill 517, track S) -> 409, row survives")
230234
void delete_nonDraft_returns409() throws Exception {
231-
mockMvc.perform(delete(ENDPOINT).param("millId", "517").param("year", "2021"))
235+
mockMvc.perform(delete(ENDPOINT).param("millId", "517").param("year", "2021")
236+
.with(csrf()))
232237
.andExpect(status().isConflict())
233238
.andExpect(jsonPath("$.detail", is("This schedule cannot be edited in its current status.")));
234239
assertEquals(1, JdbcTestUtils.countRowsInTableWhere(jdbcTemplate, SUMMARY,
@@ -240,7 +245,8 @@ void delete_nonDraft_returns409() throws Exception {
240245
@Test
241246
@DisplayName("S13 — 519 Draft DELETE 200 (SUC-002) removes summary + all details; re-GET 404")
242247
void delete_removesWholeSchedule() throws Exception {
243-
mockMvc.perform(delete(ENDPOINT).param("millId", "519").param("year", "2021"))
248+
mockMvc.perform(delete(ENDPOINT).param("millId", "519").param("year", "2021")
249+
.with(csrf()))
244250
.andExpect(status().isOk())
245251
.andExpect(jsonPath("$.message.text", is("Data deleted successfully")));
246252
assertEquals(0, JdbcTestUtils.countRowsInTableWhere(jdbcTemplate, SUMMARY,
@@ -263,21 +269,24 @@ void put_staleRevision_returns409_thenRetrySucceeds() throws Exception {
263269
// First writer wins (N -> N+1).
264270
mockMvc.perform(put(ENDPOINT)
265271
.param("millId", "520").param("year", "2021")
266-
.contentType(MediaType.APPLICATION_JSON).content(validBody(n)))
272+
.contentType(MediaType.APPLICATION_JSON).content(validBody(n))
273+
.with(csrf()))
267274
.andExpect(status().isOk())
268275
.andExpect(jsonPath("$.revisionCount", is(n + 1)));
269276
// Second writer still holds the stale token N -> rejected, no overwrite.
270277
mockMvc.perform(put(ENDPOINT)
271278
.param("millId", "520").param("year", "2021")
272-
.contentType(MediaType.APPLICATION_JSON).content(validBody(n)))
279+
.contentType(MediaType.APPLICATION_JSON).content(validBody(n))
280+
.with(csrf()))
273281
.andExpect(status().isConflict())
274282
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
275283
.andExpect(jsonPath("$.detail", is("This schedule was changed by another user. Please reload and try again.")));
276284
assertEquals(n + 1, revisionOf(1020), "the stale PUT must not overwrite");
277285
// Reload the fresh token and retry -> succeeds.
278286
mockMvc.perform(put(ENDPOINT)
279287
.param("millId", "520").param("year", "2021")
280-
.contentType(MediaType.APPLICATION_JSON).content(validBody(n + 1)))
288+
.contentType(MediaType.APPLICATION_JSON).content(validBody(n + 1))
289+
.with(csrf()))
281290
.andExpect(status().isOk())
282291
.andExpect(jsonPath("$.revisionCount", is(greaterThan(n + 1))));
283292
}

frontend/src/service/api-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ class APIService {
77
constructor() {
88
this.client = axios.create({
99
baseURL: '/api',
10+
xsrfCookieName: 'XSRF-TOKEN',
11+
xsrfHeaderName: 'X-XSRF-TOKEN',
1012
headers: {
1113
'Content-Type': 'application/json',
1214
},

0 commit comments

Comments
 (0)