feat(ap3): add CSRF and SameSite defenses
This commit is contained in:
@@ -110,3 +110,5 @@ token을 붙여 Resource Server로 proxy합니다.
|
|||||||
|
|
||||||
자세한 경계와 session 저장소 trade-off는
|
자세한 경계와 session 저장소 trade-off는
|
||||||
[`docs/ap3-bff-boundary.md`](docs/ap3-bff-boundary.md)를 참고하세요.
|
[`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
|
response.put("refreshTokenStoredOnServer", client != null
|
||||||
&& client.getRefreshToken() != null);
|
&& client.getRefreshToken() != null);
|
||||||
response.put("browserTokenCount", 0);
|
response.put("browserTokenCount", 0);
|
||||||
response.put("csrfProtectionEnabled", false);
|
response.put("csrfProtectionEnabled", true);
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.cacheControl(CacheControl.noStore())
|
.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.DefaultOAuth2AuthorizationRequestResolver;
|
||||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
|
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
@@ -30,8 +31,14 @@ public class SecurityConfig {
|
|||||||
OAuth2AuthorizationRequestCustomizers.withPkce()
|
OAuth2AuthorizationRequestCustomizers.withPkce()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CookieCsrfTokenRepository csrfTokenRepository =
|
||||||
|
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||||
|
csrfTokenRepository.setCookiePath("/");
|
||||||
|
|
||||||
return http
|
return http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf
|
||||||
|
.csrfTokenRepository(csrfTokenRepository)
|
||||||
|
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
|
||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers(
|
.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:
|
cookie:
|
||||||
name: AP3_SESSION
|
name: AP3_SESSION
|
||||||
http-only: true
|
http-only: true
|
||||||
|
same-site: lax
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ function render(value) {
|
|||||||
result.textContent = JSON.stringify(value, null, 2);
|
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 = {}) {
|
async function request(path, options = {}) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
...options,
|
...options,
|
||||||
@@ -30,10 +38,22 @@ document.querySelector("#call-bff").addEventListener("click", () => {
|
|||||||
void request("/bff/api/me");
|
void request("/bff/api/me");
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelector("#change-without-csrf").addEventListener("click", () => {
|
document.querySelector("#change-with-csrf").addEventListener("click", async () => {
|
||||||
void request("/bff/api/preferences", {
|
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",
|
method: "POST",
|
||||||
body: new URLSearchParams({ theme: "dark" }),
|
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="login" type="button">Keycloak 로그인</button>
|
||||||
<button id="inspect" type="button">token 경계 확인</button>
|
<button id="inspect" type="button">token 경계 확인</button>
|
||||||
<button id="call-bff" type="button">BFF 경유 API 호출</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>
|
<pre id="result" aria-live="polite"></pre>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="/app.js"></script>
|
<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.mock;
|
||||||
import static org.mockito.Mockito.when;
|
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.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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
@@ -52,18 +53,38 @@ class BffControllerTest {
|
|||||||
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
|
.andExpect(jsonPath("$.accessTokenStoredOnServer").value(true))
|
||||||
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
|
.andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true))
|
||||||
.andExpect(jsonPath("$.browserTokenCount").value(0))
|
.andExpect(jsonPath("$.browserTokenCount").value(0))
|
||||||
.andExpect(jsonPath("$.csrfProtectionEnabled").value(false))
|
.andExpect(jsonPath("$.csrfProtectionEnabled").value(true))
|
||||||
.andExpect(jsonPath("$.access_token").doesNotExist())
|
.andExpect(jsonPath("$.access_token").doesNotExist())
|
||||||
.andExpect(jsonPath("$.refresh_token").doesNotExist());
|
.andExpect(jsonPath("$.refresh_token").doesNotExist());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void demonstratesStateChangeWithoutCsrfProtection() throws Exception {
|
void rejectsStateChangeWithoutCsrfToken() throws Exception {
|
||||||
mockMvc.perform(post("/bff/api/preferences")
|
mockMvc.perform(post("/bff/api/preferences")
|
||||||
.param("theme", "attacker")
|
.param("theme", "attacker")
|
||||||
.with(oidcLogin().idToken(token -> token.subject("test-subject"))))
|
.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(status().isOk())
|
||||||
.andExpect(jsonPath("$.updated").value(true))
|
.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를 재시작하면 세션이 사라집니다. 다중 인스턴스 운영에서는
|
보관합니다. BFF를 재시작하면 세션이 사라집니다. 다중 인스턴스 운영에서는
|
||||||
Spring Session/Redis 같은 공유 저장소와 저장 token 암호화 정책이 필요합니다.
|
Spring Session/Redis 같은 공유 저장소와 저장 token 암호화 정책이 필요합니다.
|
||||||
|
|
||||||
## 방어 전 CSRF 재현
|
## CSRF와 SameSite 방어
|
||||||
|
|
||||||
이 feature 브랜치에서는 다음 CSRF 방어 feature와 비교하기 위해 CSRF를
|
`feature/keycloak-bff-oauth2login-session`에서는 방어 전 비교를 위해
|
||||||
의도적으로 끕니다. 다른 origin의 자동 제출 form이 브라우저 cookie를
|
CSRF를 끄고, 다른 origin의 자동 제출 form이 `/bff/api/preferences`
|
||||||
자동으로 포함해 `/bff/api/preferences` 상태를 바꾸는 것을 E2E에서
|
상태를 바꾸는 것을 재현합니다.
|
||||||
재현합니다.
|
|
||||||
|
|
||||||
이 취약 상태는 `feature/keycloak-bff-csrf-samesite-defense`에서 Spring
|
`feature/keycloak-bff-csrf-samesite-defense`에서는 다음 방어를 함께
|
||||||
CSRF token과 명시적 SameSite=Lax를 적용해 차단합니다.
|
적용합니다.
|
||||||
|
|
||||||
|
- 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 context = await browser.newContext();
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
const browserRequests = [];
|
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");
|
await page.goto("http://localhost:8083");
|
||||||
const authorizationRequestPromise = page.waitForRequest((request) =>
|
const authorizationRequestPromise = page.waitForRequest((request) =>
|
||||||
@@ -53,10 +55,11 @@ try {
|
|||||||
|
|
||||||
await page.waitForURL(/localhost:8080/u);
|
await page.waitForURL(/localhost:8080/u);
|
||||||
await completeKeycloakLogin(page);
|
await completeKeycloakLogin(page);
|
||||||
const callbackRequest = browserRequests.find((url) =>
|
const callbackRequest = browserRequests.find(({ url }) =>
|
||||||
url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"),
|
url.startsWith("http://localhost:8083/login/oauth2/code/keycloak?"),
|
||||||
);
|
);
|
||||||
assert.ok(callbackRequest, "authorization response must use the BFF callback");
|
assert.ok(callbackRequest, "authorization response must use the BFF callback");
|
||||||
|
assert.equal(callbackRequest.method, "GET");
|
||||||
|
|
||||||
const boundaryResponsePromise = page.waitForResponse((response) =>
|
const boundaryResponsePromise = page.waitForResponse((response) =>
|
||||||
response.url().endsWith("/bff/token-boundary"),
|
response.url().endsWith("/bff/token-boundary"),
|
||||||
@@ -68,7 +71,7 @@ try {
|
|||||||
assert.equal(boundary.accessTokenStoredOnServer, true);
|
assert.equal(boundary.accessTokenStoredOnServer, true);
|
||||||
assert.equal(boundary.refreshTokenStoredOnServer, true);
|
assert.equal(boundary.refreshTokenStoredOnServer, true);
|
||||||
assert.equal(boundary.browserTokenCount, 0);
|
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("access_token"), false);
|
||||||
assert.equal(JSON.stringify(boundary).includes("refresh_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.ok(resource.audience.includes("keycloak-pattern-api"));
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
browserRequests.some((url) => url.startsWith("http://localhost:8081/")),
|
browserRequests.some(({ url }) =>
|
||||||
|
url.startsWith("http://localhost:8081/"),
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
"the browser must not bypass the BFF",
|
"the browser must not bypass the BFF",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
browserRequests.some((url) =>
|
browserRequests.some(({ url }) =>
|
||||||
url.includes("/protocol/openid-connect/token"),
|
url.includes("/protocol/openid-connect/token"),
|
||||||
),
|
),
|
||||||
false,
|
false,
|
||||||
@@ -99,6 +104,7 @@ try {
|
|||||||
const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION");
|
const sessionCookie = cookies.find((cookie) => cookie.name === "AP3_SESSION");
|
||||||
assert.ok(sessionCookie);
|
assert.ok(sessionCookie);
|
||||||
assert.equal(sessionCookie.httpOnly, true);
|
assert.equal(sessionCookie.httpOnly, true);
|
||||||
|
assert.equal(sessionCookie.sameSite, "Lax");
|
||||||
|
|
||||||
const storage = await page.evaluate(() => ({
|
const storage = await page.evaluate(() => ({
|
||||||
localStorage: Object.values(localStorage),
|
localStorage: Object.values(localStorage),
|
||||||
@@ -109,11 +115,61 @@ try {
|
|||||||
assert.deepEqual(storage.sessionStorage, []);
|
assert.deepEqual(storage.sessionStorage, []);
|
||||||
assert.equal(storage.readableCookies.includes("AP3_SESSION"), false);
|
assert.equal(storage.readableCookies.includes("AP3_SESSION"), false);
|
||||||
|
|
||||||
await page.goto("http://localhost:8088");
|
const missingCsrfResponse = await page.evaluate(async () => {
|
||||||
const forgedResponsePromise = page.waitForResponse(
|
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) =>
|
||||||
response.url() === "http://localhost:8083/bff/api/preferences" &&
|
response.url().endsWith("/bff/api/preferences") &&
|
||||||
response.request().method() === "POST",
|
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(() => {
|
await page.evaluate(() => {
|
||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
@@ -121,19 +177,21 @@ try {
|
|||||||
form.action = "http://localhost:8083/bff/api/preferences";
|
form.action = "http://localhost:8083/bff/api/preferences";
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.name = "theme";
|
input.name = "theme";
|
||||||
input.value = "attacker";
|
input.value = "cross-site-attacker";
|
||||||
form.append(input);
|
form.append(input);
|
||||||
document.body.append(form);
|
document.body.append(form);
|
||||||
form.submit();
|
form.submit();
|
||||||
});
|
});
|
||||||
const forgedResponse = await forgedResponsePromise;
|
const crossSiteRequest = await crossSiteRequestPromise;
|
||||||
assert.equal(forgedResponse.status(), 200);
|
const crossSiteHeaders = await crossSiteRequest.allHeaders();
|
||||||
const forgedResult = await forgedResponse.json();
|
assert.equal(
|
||||||
assert.equal(forgedResult.updated, true);
|
(crossSiteHeaders.cookie ?? "").includes("AP3_SESSION="),
|
||||||
assert.equal(forgedResult.theme, "attacker");
|
false,
|
||||||
|
"SameSite=Lax must omit the session cookie on a cross-site POST",
|
||||||
|
);
|
||||||
|
|
||||||
console.log(
|
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 {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user