feat(ap3): add CSRF and SameSite defenses
This commit is contained in:
+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