feat(ap3): add CSRF and SameSite defenses

This commit is contained in:
donghyeon-ka
2026-07-25 14:42:33 +09:00
parent b48a12f101
commit 4495eb63a0
11 changed files with 214 additions and 32 deletions
@@ -56,7 +56,7 @@ public class BffController {
response.put("refreshTokenStoredOnServer", client != null
&& client.getRefreshToken() != null);
response.put("browserTokenCount", 0);
response.put("csrfProtectionEnabled", false);
response.put("csrfProtectionEnabled", true);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
@@ -0,0 +1,25 @@
package com.example.keycloakpattern.bff;
import java.util.Map;
import org.springframework.http.CacheControl;
import org.springframework.http.ResponseEntity;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CsrfController {
@GetMapping("/bff/csrf")
ResponseEntity<Map<String, String>> csrf(CsrfToken csrfToken) {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header("Pragma", "no-cache")
.body(Map.of(
"headerName", csrfToken.getHeaderName(),
"parameterName", csrfToken.getParameterName(),
"token", csrfToken.getToken()
));
}
}
@@ -12,6 +12,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
public class SecurityConfig {
@@ -30,8 +31,14 @@ public class SecurityConfig {
OAuth2AuthorizationRequestCustomizers.withPkce()
);
CookieCsrfTokenRepository csrfTokenRepository =
CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfTokenRepository.setCookiePath("/");
return http
.csrf(csrf -> csrf.disable())
.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository)
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/",
@@ -0,0 +1,40 @@
package com.example.keycloakpattern.bff;
import java.util.function.Supplier;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
private final CsrfTokenRequestHandler plain =
new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor =
new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> deferredCsrfToken
) {
xor.handle(request, response, deferredCsrfToken);
}
@Override
public String resolveCsrfTokenValue(
HttpServletRequest request,
CsrfToken csrfToken
) {
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return plain.resolveCsrfTokenValue(request, csrfToken);
}
return xor.resolveCsrfTokenValue(request, csrfToken);
}
}
+1
View File
@@ -5,6 +5,7 @@ server:
cookie:
name: AP3_SESSION
http-only: true
same-site: lax
spring:
application:
+23 -3
View File
@@ -4,6 +4,14 @@ function render(value) {
result.textContent = JSON.stringify(value, null, 2);
}
function readCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const value = document.cookie
.split("; ")
.find((cookie) => cookie.startsWith(prefix));
return value ? decodeURIComponent(value.slice(prefix.length)) : null;
}
async function request(path, options = {}) {
const response = await fetch(path, {
...options,
@@ -30,10 +38,22 @@ document.querySelector("#call-bff").addEventListener("click", () => {
void request("/bff/api/me");
});
document.querySelector("#change-without-csrf").addEventListener("click", () => {
void request("/bff/api/preferences", {
document.querySelector("#change-with-csrf").addEventListener("click", async () => {
const csrfResponse = await fetch("/bff/csrf", {
headers: { Accept: "application/json" },
});
const csrf = await csrfResponse.json();
const csrfToken = readCookie("XSRF-TOKEN");
if (!csrfToken) {
render({ status: 500, error: "XSRF-TOKEN cookie was not created" });
return;
}
await request("/bff/api/preferences", {
method: "POST",
body: new URLSearchParams({ theme: "dark" }),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
[csrf.headerName]: csrfToken,
},
});
});
+1 -1
View File
@@ -23,7 +23,7 @@
<button id="login" type="button">Keycloak 로그인</button>
<button id="inspect" type="button">token 경계 확인</button>
<button id="call-bff" type="button">BFF 경유 API 호출</button>
<button id="change-without-csrf" type="button">CSRF token 없이 상태 변경</button>
<button id="change-with-csrf" type="button">CSRF token으로 상태 변경</button>
<pre id="result" aria-live="polite"></pre>
</main>
<script type="module" src="/app.js"></script>
@@ -3,6 +3,7 @@ package com.example.keycloakpattern.bff;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@@ -52,18 +53,38 @@ class BffControllerTest {
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
.andExpect(jsonPath("$.browserTokenCount").value(0))
.andExpect(jsonPath("$.csrfProtectionEnabled").value(false))
.andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
.andExpect(jsonPath("$.access_token").doesNotExist())
.andExpect(jsonPath("$.refresh_token").doesNotExist());
}
@Test
void demonstratesStateChangeWithoutCsrfProtection() throws Exception {
void rejectsStateChangeWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "attacker")
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
.andExpect(status().isForbidden());
}
@Test
void acceptsStateChangeWithCsrfToken() throws Exception {
mockMvc.perform(post("/bff/api/preferences")
.param("theme", "dark")
.with(oidcLogin().idToken(token -> token.subject("test-subject")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(true))
.andExpect(jsonPath("$.theme").value("attacker"));
.andExpect(jsonPath("$.theme").value("dark"));
}
@Test
void exposesSpaCsrfTokenWithoutCaching() throws Exception {
mockMvc.perform(get("/bff/csrf").with(oidcLogin()
.idToken(token -> token.subject("test-subject"))))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().exists("Set-Cookie"))
.andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN"))
.andExpect(jsonPath("$.token").isNotEmpty());
}
}