diff --git a/README.md b/README.md index 670617c..20c5ed1 100644 --- a/README.md +++ b/README.md @@ -122,3 +122,8 @@ Keycloak은 `KC_HOSTNAME=http://localhost:8080`을 기준으로 token의 `iss` Docker 내부의 `http://keycloak:8080`에서 가져옵니다. 진단 인스턴스는 일부러 `http://wrong-issuer.invalid`를 기대하도록 구성되어, 서명과 audience가 정상이더라도 issuer mismatch로 `401`을 반환합니다. + +token 저장 위치와 XSS 범위는 +[`docs/ap1-token-storage.md`](docs/ap1-token-storage.md)에 정리했습니다. +E2E는 Web Storage token이 0개임과 동시에 실행 중 fetch hook이 Bearer +header를 관찰할 수 있음을 재현합니다. diff --git a/docs/ap1-token-storage.md b/docs/ap1-token-storage.md new file mode 100644 index 0000000..cd61baf --- /dev/null +++ b/docs/ap1-token-storage.md @@ -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에는 여전히 노출”이다. diff --git a/e2e/pattern1.mjs b/e2e/pattern1.mjs index ea20a31..0f4ddf5 100644 --- a/e2e/pattern1.mjs +++ b/e2e/pattern1.mjs @@ -36,8 +36,32 @@ try { assert.equal(authorizationUrl?.searchParams.get("code_challenge_method"), "S256"); assert.ok(authorizationUrl?.searchParams.get("code_challenge")); - const accessToken = await page.evaluate(() => window.__pattern1.getAccessToken()); - assert.ok(accessToken, "access token must exist in browser memory"); + await page.evaluate(() => { + 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( Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"), ); @@ -56,12 +80,10 @@ try { false, "access token must not be persisted in Web Storage", ); - - await page.locator("#call-api").click(); - await page.waitForFunction(() => { - const text = document.querySelector("#result")?.textContent ?? ""; - return text.includes('"httpStatus": 200'); - }); + assert.ok( + capturedAuthorization, + "runtime XSS-style fetch hooking can still observe a memory-only bearer token", + ); if (process.env.WRONG_AUDIENCE_URL) { const response = await fetch(process.env.WRONG_AUDIENCE_URL, { @@ -88,13 +110,19 @@ try { await page.reload(); await page.locator('[data-authenticated="false"]').waitFor(); assert.equal( - await page.evaluate(() => window.__pattern1.getAccessToken()), - null, - "reload must clear the memory-only token", + await page.evaluate( + (token) => JSON.stringify({ + localStorage: Object.values(localStorage), + sessionStorage: Object.values(sessionStorage), + }).includes(token), + accessToken, + ), + false, + "reload must clear the memory-only token without persisting it", ); 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 { await browser.close(); diff --git a/frontend/src/app.js b/frontend/src/app.js index 183bf7e..d89a820 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -122,13 +122,6 @@ userManager.events.addUserLoaded(renderSession); userManager.events.addUserUnloaded(() => 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 { const callbackUser = await finishSigninCallback(); renderSession(callbackUser ?? await userManager.getUser());