From d5946493ca927c77e378ee475cb4d167d20b4da6 Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 12:37:48 +0900 Subject: [PATCH 1/2] fix(api): sanitize session list responses --- apps/api/src/auth.test.ts | 59 +++++++++++++++++++++++++++++++++++++-- apps/api/src/auth.ts | 58 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index a786dfc..f160b45 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -1,13 +1,47 @@ import { beforeAll, describe, expect, it } from "vitest"; import { getTestInstance } from "better-auth/test"; -import { createPublicSessionPlugin } from "./auth.js"; +import { + createPublicSessionListPlugin, + createPublicSessionPlugin, +} from "./auth.js"; function createTestAuth() { return getTestInstance({ - plugins: [createPublicSessionPlugin()], + plugins: [createPublicSessionPlugin(), createPublicSessionListPlugin()], }); } +async function expectSafeSessionList(response: Response) { + expect(response.ok).toBe(true); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + + const body = (await response.json()) as Record[]; + + expect(body.length).toBeGreaterThan(0); + for (const session of body) { + expect(Object.keys(session).sort()).toEqual( + [ + "id", + "userId", + "expiresAt", + "createdAt", + "updatedAt", + "ipAddress", + "userAgent", + ].sort(), + ); + expect(session.id).toEqual(expect.any(String)); + expect(session.userId).toEqual(expect.any(String)); + expect(session.expiresAt).toEqual(expect.any(String)); + expect(session.createdAt).toEqual(expect.any(String)); + expect(session.updatedAt).toEqual(expect.any(String)); + expect(session).toHaveProperty("ipAddress"); + expect(session).toHaveProperty("userAgent"); + expect(session).not.toHaveProperty("token"); + } + expect(JSON.stringify(body)).not.toContain(bearerToken); +} + type TestInstance = Awaited>; let instance: TestInstance; @@ -73,3 +107,24 @@ describe("GET /api/auth/get-session", () => { await expectSafeSession(response); }); }); + +describe("GET /api/auth/list-sessions", () => { + it("Cookie 認証では非機密フィールドだけを返す", async () => { + const { headers } = await instance.signInWithTestUser(); + const response = await instance.customFetchImpl( + "http://localhost:3000/api/auth/list-sessions", + { headers }, + ); + + await expectSafeSessionList(response); + }); + + it("Bearer 認証を維持しつつ非機密フィールドだけを返す", async () => { + const response = await instance.customFetchImpl( + "http://localhost:3000/api/auth/list-sessions", + { headers: { Authorization: `Bearer ${bearerToken}` } }, + ); + + await expectSafeSessionList(response); + }); +}); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index bad0729..5d3bc7d 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -1,5 +1,5 @@ import { createAuthMiddleware } from "better-auth/api"; -import { betterAuth } from "better-auth"; +import { betterAuth, type BetterAuthPlugin } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { bearer, customSession } from "better-auth/plugins"; import { getDb } from "./db/index.js"; @@ -32,6 +32,56 @@ export function createPublicSessionPlugin() { }); } +const publicSessionFields = [ + "id", + "userId", + "expiresAt", + "createdAt", + "updatedAt", + "ipAddress", + "userAgent", +] as const; + +async function getSessionList(returned: unknown): Promise { + if (returned instanceof Response) { + if (!returned.ok) return null; + + const body: unknown = await returned.clone().json(); + return Array.isArray(body) ? body : null; + } + + return Array.isArray(returned) ? returned : null; +} + +export function createPublicSessionListPlugin() { + return { + id: "public-session-list", + hooks: { + after: [ + { + matcher: (ctx) => ctx.path === "/list-sessions", + handler: createAuthMiddleware(async (ctx) => { + const sessions = await getSessionList(ctx.context.returned); + if (!sessions) return; + + ctx.setHeader("Cache-Control", "private, no-store"); + + return ctx.json( + sessions.map((session) => { + const source = session as Record; + + return Object.fromEntries( + publicSessionFields.map((field) => [field, source[field]]), + ); + }), + ); + }), + }, + ], + }, + } satisfies BetterAuthPlugin; +} + function createAuth() { return betterAuth({ database: drizzleAdapter(getDb(), { @@ -42,7 +92,11 @@ function createAuth() { emailAndPassword: { enabled: true, }, - plugins: [bearer(), createPublicSessionPlugin()], + plugins: [ + bearer(), + createPublicSessionPlugin(), + createPublicSessionListPlugin(), + ], trustedOrigins: process.env.TRUSTED_ORIGINS ? process.env.TRUSTED_ORIGINS.split(",") .map((s) => s.trim()) From e81dc2f209b49002aa758ebf83ad61dbc34ed69a Mon Sep 17 00:00:00 2001 From: Hiroki SAKABE Date: Wed, 12 Aug 2026 12:43:48 +0900 Subject: [PATCH 2/2] test(api): preserve unauthenticated session list rejection --- apps/api/src/auth.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index f160b45..192e300 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -109,6 +109,14 @@ describe("GET /api/auth/get-session", () => { }); describe("GET /api/auth/list-sessions", () => { + it("未認証では 401 を返す", async () => { + const response = await instance.customFetchImpl( + "http://localhost:3000/api/auth/list-sessions", + ); + + expect(response.status).toBe(401); + }); + it("Cookie 認証では非機密フィールドだけを返す", async () => { const { headers } = await instance.signInWithTestUser(); const response = await instance.customFetchImpl(