merge: AP3 CSRF and SameSite defenses
This commit is contained in:
@@ -110,3 +110,5 @@ token을 붙여 Resource Server로 proxy합니다.
|
||||
|
||||
자세한 경계와 session 저장소 trade-off는
|
||||
[`docs/ap3-bff-boundary.md`](docs/ap3-bff-boundary.md)를 참고하세요.
|
||||
최종 AP3 branch는 `AP3_SESSION; HttpOnly; SameSite=Lax`와 Spring CSRF
|
||||
token을 함께 사용하며, token 없는 상태 변경 요청은 403으로 거부합니다.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ server:
|
||||
cookie:
|
||||
name: AP3_SESSION
|
||||
http-only: true
|
||||
same-site: lax
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,20 @@ Resource Server는 `aud=keycloak-pattern-api`를 검증합니다. 브라우저
|
||||
보관합니다. BFF를 재시작하면 세션이 사라집니다. 다중 인스턴스 운영에서는
|
||||
Spring Session/Redis 같은 공유 저장소와 저장 token 암호화 정책이 필요합니다.
|
||||
|
||||
## 방어 전 CSRF 재현
|
||||
## CSRF와 SameSite 방어
|
||||
|
||||
이 feature 브랜치에서는 다음 CSRF 방어 feature와 비교하기 위해 CSRF를
|
||||
의도적으로 끕니다. 다른 origin의 자동 제출 form이 브라우저 cookie를
|
||||
자동으로 포함해 `/bff/api/preferences` 상태를 바꾸는 것을 E2E에서
|
||||
재현합니다.
|
||||
`feature/keycloak-bff-oauth2login-session`에서는 방어 전 비교를 위해
|
||||
CSRF를 끄고, 다른 origin의 자동 제출 form이 `/bff/api/preferences`
|
||||
상태를 바꾸는 것을 재현합니다.
|
||||
|
||||
이 취약 상태는 `feature/keycloak-bff-csrf-samesite-defense`에서 Spring
|
||||
CSRF token과 명시적 SameSite=Lax를 적용해 차단합니다.
|
||||
`feature/keycloak-bff-csrf-samesite-defense`에서는 다음 방어를 함께
|
||||
적용합니다.
|
||||
|
||||
- Spring synchronizer CSRF token과 `CookieCsrfTokenRepository`
|
||||
- JS가 읽는 `XSRF-TOKEN`과 요청의 `X-XSRF-TOKEN` header
|
||||
- HttpOnly `AP3_SESSION` cookie의 명시적 `SameSite=Lax`
|
||||
|
||||
E2E는 token 없는 동일 위조 POST가 403이 되는 것, CSRF header가 있는
|
||||
정상 POST는 200인 것, cross-site POST에는 AP3 session cookie가 제외되는
|
||||
것을 각각 확인합니다. SameSite는 CSRF token을 대체하지 않는
|
||||
defense-in-depth입니다.
|
||||
|
||||
+74
-16
@@ -36,7 +36,9 @@ try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const browserRequests = [];
|
||||
page.on("request", (request) => browserRequests.push(request.url()));
|
||||
page.on("request", (request) =>
|
||||
browserRequests.push({ method: request.method(), url: request.url() }),
|
||||
);
|
||||
|
||||
await page.goto("http://localhost:8083");
|
||||
const authorizationRequestPromise = page.waitForRequest((request) =>
|
||||
@@ -53,10 +55,11 @@ try {
|
||||
|
||||
await page.waitForURL(/localhost:8080/u);
|
||||
await completeKeycloakLogin(page);
|
||||
const callbackRequest = browserRequests.find((url) =>
|
||||
const callbackRequest = browserRequests.find(({ url }) =>
|
||||
url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"),
|
||||
);
|
||||
assert.ok(callbackRequest, "authorization response must use the BFF callback");
|
||||
assert.equal(callbackRequest.method, "GET");
|
||||
|
||||
const boundaryResponsePromise = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/bff/token-boundary"),
|
||||
@@ -68,7 +71,7 @@ try {
|
||||
assert.equal(boundary.accessTokenStoredOnServer, true);
|
||||
assert.equal(boundary.refreshTokenStoredOnServer, true);
|
||||
assert.equal(boundary.browserTokenCount, 0);
|
||||
assert.equal(boundary.csrfProtectionEnabled, false);
|
||||
assert.equal(boundary.csrfProtectionEnabled, true);
|
||||
assert.equal(JSON.stringify(boundary).includes("access_token"), false);
|
||||
assert.equal(JSON.stringify(boundary).includes("refresh_token"), false);
|
||||
|
||||
@@ -83,12 +86,14 @@ try {
|
||||
assert.ok(resource.audience.includes("keycloak-pattern-api"));
|
||||
|
||||
assert.equal(
|
||||
browserRequests.some((url) => url.startsWith("http://localhost:8081/")),
|
||||
browserRequests.some(({ url }) =>
|
||||
url.startsWith("http://localhost:8081/"),
|
||||
),
|
||||
false,
|
||||
"the browser must not bypass the BFF",
|
||||
);
|
||||
assert.equal(
|
||||
browserRequests.some((url) =>
|
||||
browserRequests.some(({ url }) =>
|
||||
url.includes("/protocol/openid-connect/token"),
|
||||
),
|
||||
false,
|
||||
@@ -99,6 +104,7 @@ try {
|
||||
const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION");
|
||||
assert.ok(sessionCookie);
|
||||
assert.equal(sessionCookie.httpOnly, true);
|
||||
assert.equal(sessionCookie.sameSite, "Lax");
|
||||
|
||||
const storage = await page.evaluate(() => ({
|
||||
localStorage: Object.values(localStorage),
|
||||
@@ -109,11 +115,61 @@ try {
|
||||
assert.deepEqual(storage.sessionStorage, []);
|
||||
assert.equal(storage.readableCookies.includes("AP3_SESSION"), false);
|
||||
|
||||
await page.goto("http://localhost:8088");
|
||||
const forgedResponsePromise = page.waitForResponse(
|
||||
const missingCsrfResponse = await page.evaluate(async () => {
|
||||
const response = await fetch("/bff/api/preferences", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams({ theme: "missing-csrf" }),
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
return response.status;
|
||||
});
|
||||
assert.equal(missingCsrfResponse, 403);
|
||||
|
||||
const csrfResponsePromise = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/bff/csrf"),
|
||||
);
|
||||
const validChangeResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url() === "http://localhost:8083/bff/api/preferences" &&
|
||||
response.request().method() === "POST",
|
||||
response.url().endsWith("/bff/api/preferences") &&
|
||||
response.request().method() === "POST" &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await page.locator("#change-with-csrf").click();
|
||||
const csrfResponse = await csrfResponsePromise;
|
||||
const validChangeResponse = await validChangeResponsePromise;
|
||||
assert.equal(csrfResponse.status(), 200);
|
||||
assert.equal(validChangeResponse.status(), 200);
|
||||
const validChange = await validChangeResponse.json();
|
||||
assert.equal(validChange.theme, "dark");
|
||||
|
||||
const csrfCookies = await context.cookies("http://localhost:8083/");
|
||||
const csrfCookie = csrfCookies.find((cookie) => cookie.name === "XSRF-TOKEN");
|
||||
assert.ok(csrfCookie);
|
||||
assert.equal(csrfCookie.httpOnly, false);
|
||||
|
||||
await page.goto("http://localhost:8088");
|
||||
const [forgedResponse] = await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
page.evaluate(() => {
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "http://localhost:8083/bff/api/preferences";
|
||||
const input = document.createElement("input");
|
||||
input.name = "theme";
|
||||
input.value = "attacker";
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}),
|
||||
]);
|
||||
assert.ok(forgedResponse);
|
||||
assert.equal(forgedResponse.status(), 403);
|
||||
|
||||
await page.goto("http://127.0.0.1:8088");
|
||||
const crossSiteRequestPromise = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url() === "http://localhost:8083/bff/api/preferences" &&
|
||||
request.method() === "POST",
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
const form = document.createElement("form");
|
||||
@@ -121,19 +177,21 @@ try {
|
||||
form.action = "http://localhost:8083/bff/api/preferences";
|
||||
const input = document.createElement("input");
|
||||
input.name = "theme";
|
||||
input.value = "attacker";
|
||||
input.value = "cross-site-attacker";
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
});
|
||||
const forgedResponse = await forgedResponsePromise;
|
||||
assert.equal(forgedResponse.status(), 200);
|
||||
const forgedResult = await forgedResponse.json();
|
||||
assert.equal(forgedResult.updated, true);
|
||||
assert.equal(forgedResult.theme, "attacker");
|
||||
const crossSiteRequest = await crossSiteRequestPromise;
|
||||
const crossSiteHeaders = await crossSiteRequest.allHeaders();
|
||||
assert.equal(
|
||||
(crossSiteHeaders.cookie ?? "").includes("AP3_SESSION="),
|
||||
false,
|
||||
"SameSite=Lax must omit the session cookie on a cross-site POST",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"pattern3 BFF verified: browser token 0, session-only proxy 200, pre-defense CSRF reproduced",
|
||||
"pattern3 BFF verified: tokenless POST 403, CSRF header 200, SameSite=Lax session",
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
Reference in New Issue
Block a user