merge(ap1): token storage tradeoff
This commit is contained in:
@@ -122,3 +122,8 @@ Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss`
|
|||||||
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
|
Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러
|
||||||
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
|
`http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가
|
||||||
정상이더라도 issuer mismatch로 `401`을 반환합니다.
|
정상이더라도 issuer mismatch로 `401`을 반환합니다.
|
||||||
|
|
||||||
|
token 저장 위치와 XSS 범위는
|
||||||
|
[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다.
|
||||||
|
E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer
|
||||||
|
header를 관찰할 수 있음을 재현합니다.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# AP1 token storage trade-off
|
||||||
|
|
||||||
|
AP1에서는 `access_token`, `refresh_token`, `id_token`을
|
||||||
|
`oidc-client-ts`의 명시적인 `InMemoryWebStorage`에만 보관한다.
|
||||||
|
`localStorage`와 `sessionStorage`에는 OAuth token을 저장하지 않는다.
|
||||||
|
|
||||||
|
full-page authorization redirect를 생존해야 하는 일회성 transaction
|
||||||
|
state와 PKCE verifier만 `sessionStorage`를 사용한다. callback 성공 후
|
||||||
|
라이브러리가 해당 transaction state를 제거한다.
|
||||||
|
|
||||||
|
| 저장 위치 | reload 생존 | JavaScript 접근 | AP1 선택 |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| 메모리 | 아니요 | 실행 중 가능 | 사용 |
|
||||||
|
| `sessionStorage` | 같은 탭에서 가능 | 가능 | token 저장 금지 |
|
||||||
|
| `localStorage` | 예 | 가능 | token 저장 금지 |
|
||||||
|
| HttpOnly cookie | 가능 | raw token 접근 불가 | AP2/AP3의 서버 소유 경계 |
|
||||||
|
|
||||||
|
메모리 저장은 XSS를 제거하지 않는다. 악성 스크립트가 실행 중 `fetch`를
|
||||||
|
후킹하면 SPA가 붙이는 `Authorization: Bearer ...` 헤더를 관찰할 수 있다.
|
||||||
|
다만 persistent storage를 사용하지 않으므로 reload 이후 탈취 가능한 token
|
||||||
|
복사본이 남지 않는다.
|
||||||
|
|
||||||
|
`e2e/pattern1.mjs`는 다음 두 조건을 동시에 검증한다.
|
||||||
|
|
||||||
|
1. access token이 Web Storage 어디에도 존재하지 않는다.
|
||||||
|
2. 실행 중 fetch hook은 Bearer token을 관찰할 수 있다.
|
||||||
|
|
||||||
|
따라서 결론은 “메모리면 XSS에 안전”이 아니라 “영속 탈취 범위를 줄이지만
|
||||||
|
실행 중 XSS에는 여전히 노출”이다.
|
||||||
+40
-12
@@ -36,8 +36,32 @@ try {
|
|||||||
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256");
|
||||||
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
assert.ok(authorizationUrl?.searchParams.get("code_challenge"));
|
||||||
|
|
||||||
const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken());
|
await page.evaluate(() => {
|
||||||
assert.ok(accessToken, "access token must exist in browser memory");
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
window.__xssProbe = { authorization: null };
|
||||||
|
window.fetch = (input, init = {}) => {
|
||||||
|
const headers = new Headers(
|
||||||
|
init.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||||
|
);
|
||||||
|
const authorization = headers.get("Authorization");
|
||||||
|
if (authorization) {
|
||||||
|
window.__xssProbe.authorization = authorization;
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator("#call-api").click();
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const text = document.querySelector("#result")?.textContent ?? "";
|
||||||
|
return text.includes('"httpStatus": 200');
|
||||||
|
});
|
||||||
|
|
||||||
|
const capturedAuthorization = await page.evaluate(
|
||||||
|
() => window.__xssProbe.authorization,
|
||||||
|
);
|
||||||
|
assert.match(capturedAuthorization, /^Bearer /u);
|
||||||
|
const accessToken = capturedAuthorization.slice("Bearer ".length);
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
|
||||||
);
|
);
|
||||||
@@ -56,12 +80,10 @@ try {
|
|||||||
false,
|
false,
|
||||||
"access token must not be persisted in Web Storage",
|
"access token must not be persisted in Web Storage",
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
await page.locator("#call-api").click();
|
capturedAuthorization,
|
||||||
await page.waitForFunction(() => {
|
"runtime XSS-style fetch hooking can still observe a memory-only bearer token",
|
||||||
const text = document.querySelector("#result")?.textContent ?? "";
|
);
|
||||||
return text.includes('"httpStatus": 200');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (process.env.WRONG_AUDIENCE_URL) {
|
if (process.env.WRONG_AUDIENCE_URL) {
|
||||||
const response = await fetch(process.env.WRONG_AUDIENCE_URL, {
|
const response = await fetch(process.env.WRONG_AUDIENCE_URL, {
|
||||||
@@ -88,13 +110,19 @@ try {
|
|||||||
await page.reload();
|
await page.reload();
|
||||||
await page.locator('[data-authenticated="false"]').waitFor();
|
await page.locator('[data-authenticated="false"]').waitFor();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(() => window.__pattern1.getAccessToken()),
|
await page.evaluate(
|
||||||
null,
|
(token) => JSON.stringify({
|
||||||
"reload must clear the memory-only token",
|
localStorage: Object.values(localStorage),
|
||||||
|
sessionStorage: Object.values(sessionStorage),
|
||||||
|
}).includes(token),
|
||||||
|
accessToken,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
"reload must clear the memory-only token without persisting it",
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"pattern1 browser verified: code+PKCE S256, audience/issuer negatives 401, Web Storage token 0, reload clears token",
|
"pattern1 browser verified: PKCE, audience/issuer negatives, persistent token 0, runtime fetch hook observes bearer, reload clears token",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -122,13 +122,6 @@ userManager.events.addUserLoaded(renderSession);
|
|||||||
userManager.events.addUserUnloaded(() => renderSession(null));
|
userManager.events.addUserUnloaded(() => renderSession(null));
|
||||||
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
userManager.events.addAccessTokenExpired(() => renderSession(null));
|
||||||
|
|
||||||
window.__pattern1 = {
|
|
||||||
getAccessToken: () => currentUser?.access_token ?? null,
|
|
||||||
getRefreshToken: () => currentUser?.refresh_token ?? null,
|
|
||||||
getIdToken: () => currentUser?.id_token ?? null,
|
|
||||||
callProtectedApi,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbackUser = await finishSigninCallback();
|
const callbackUser = await finishSigninCallback();
|
||||||
renderSession(callbackUser ?? await userManager.getUser());
|
renderSession(callbackUser ?? await userManager.getUser());
|
||||||
|
|||||||
Reference in New Issue
Block a user