diff --git a/app/game-api/src/realtime/authenticatedConnection.ts b/app/game-api/src/realtime/authenticatedConnection.ts new file mode 100644 index 00000000..da0bb12f --- /dev/null +++ b/app/game-api/src/realtime/authenticatedConnection.ts @@ -0,0 +1,86 @@ +import type { ServerResponse } from 'node:http'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; + +const MAX_PENDING_FRAMES = 32; +const MAX_BUFFER_BYTES = 64 * 1024; + +/** Revalidate before work and before writing its result; never retain unbounded frames. */ +export class AuthenticatedRealtimeConnection { + private queue = Promise.resolve(); + private pending = 0; + private ended = false; + private readonly expiryTimer: ReturnType; + private cleanup = new Set<() => void>(); + + constructor( + readonly auth: GameSessionTokenPayload, + private readonly response: ServerResponse, + private readonly resolveAuth: () => Promise + ) { + // AccessTokenStore requires a full remaining TTL second on every lookup. + this.expiryTimer = setTimeout(() => this.close(), Math.max(0, Date.parse(auth.expiresAt) - Date.now() - 999)); + this.expiryTimer.unref(); + response.once('close', this.close); + response.once('error', this.close); + } + + get closed(): boolean { + return this.ended; + } + + onClose(cleanup: () => void): void { + if (this.ended) cleanup(); + else this.cleanup.add(cleanup); + } + + private async currentAuth(): Promise { + if (this.ended) return null; + const current = await this.resolveAuth(); + if ( + !current || + this.ended || + current.sessionId !== this.auth.sessionId || + current.user.id !== this.auth.user.id || + current.profile !== this.auth.profile + ) { + this.close(); + return null; + } + return current; + } + + enqueue(work: (auth: GameSessionTokenPayload) => Promise): void { + if (this.ended) return; + if (this.pending >= MAX_PENDING_FRAMES || this.response.writableLength > MAX_BUFFER_BYTES) { + this.close(); + return; + } + this.pending += 1; + this.queue = this.queue + .then(async () => { + const current = await this.currentAuth(); + if (!current) return; + const frame = await work(current); + if (!frame || !(await this.currentAuth())) return; + if (Buffer.byteLength(frame) > MAX_BUFFER_BYTES || !this.response.write(frame)) this.close(); + }) + .catch(() => { + // Redis/auth failure is fail closed. Reconnect must authenticate again. + this.close(); + }) + .finally(() => { + this.pending -= 1; + }); + } + + close = (): void => { + if (this.ended) return; + this.ended = true; + clearTimeout(this.expiryTimer); + this.response.off('close', this.close); + this.response.off('error', this.close); + for (const cleanup of this.cleanup) cleanup(); + this.cleanup.clear(); + if (!this.response.destroyed && !this.response.writableEnded) this.response.end(); + }; +} diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index c82830c7..8adeb378 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -1,3 +1,4 @@ +import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common'; import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; @@ -32,6 +33,8 @@ import { appRouter } from './router.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisRealtimeEventHub } from './realtime/eventHub.js'; +import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; +import { AuthenticatedRealtimeConnection } from './realtime/authenticatedConnection.js'; import { formatSseFrame } from './realtime/sse.js'; import { shouldForwardRealtimeEvent, @@ -99,11 +102,16 @@ export const createGameApiServer = async () => { imageUploadSecret ); const app = fastify({ - logger: true, + logger: safeHttpLoggerOptions, routerOptions: { maxParamLength: 2048, }, }); + app.setErrorHandler(safeHttpErrorHandler); + const realtimeConnections = new Set(); + app.addHook('preClose', async () => { + for (const connection of realtimeConnections) connection.close(); + }); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); const redis = createRedisConnector(resolveRedisConfigFromEnv()); @@ -150,6 +158,12 @@ export const createGameApiServer = async () => { config.flushChannel, flushStore, async (event) => { + const flushedAt = Date.parse(event.flushedAt); + for (const connection of realtimeConnections) { + if (connection.auth.user.id === event.userId && Date.parse(connection.auth.issuedAt) <= flushedAt) { + connection.close(); + } + } await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]); }, (error, event) => { @@ -299,14 +313,24 @@ export const createGameApiServer = async () => { }, }); + const resolveRealtimeAuth = async (token: string | null): Promise => { + const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); + if ( + !auth || + auth.profile !== config.profileName || + isGameAccessBlocked(auth.sanctions, [config.profileName, config.profile]) + ) + return null; + return auth; + }; + app.get(config.eventsPath, async (request, reply) => { const query = request.query as { token?: string; scope?: string }; const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard'; const tokenFromHeader = extractBearerToken(request.headers.authorization); - const tokenFromQuery = typeof query.token === 'string' ? query.token : null; - const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore); + const auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null; - if (!auth) { + if (!auth || auth.profile !== config.profileName) { await reply.status(401).send({ ok: false, error: 'unauthorized' }); return; } @@ -351,88 +375,62 @@ export const createGameApiServer = async () => { reply.raw.setTimeout?.(0); reply.raw.flushHeaders?.(); - const sendFrame = (payload: string) => { - try { - reply.raw.write(payload); - } catch { - return; - } - }; - - sendFrame( - formatSseFrame({ - event: 'ready', - data: '{}', - }) + const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () => + resolveRealtimeAuth(tokenFromHeader) ); - - let closed = false; - let eventQueue = Promise.resolve(); + realtimeConnections.add(connection); + connection.onClose(() => { + realtimeConnections.delete(connection); + }); + connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' })); const unsubscribe = realtimeHub.subscribe((event) => { if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return; - eventQueue = eventQueue - .then(async () => { - if (closed) return; - const identities = [viewerIdentity]; - if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { - const nextIdentity = await loadViewerIdentity(); - identities.push(nextIdentity); - viewerIdentity = nextIdentity; - } - let refreshGrant: string | undefined; - const publicEvent = toPublicRealtimeEvent(event, identities, () => { - refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret); - return refreshGrant; - }); - if (!publicEvent || closed) return; - if (refreshGrant) { - try { - await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName); - } catch { - // Preserve the invalidation. An unregistered grant safely falls back - // to the normal scored refresh path. - } - } - sendFrame( - formatSseFrame({ - event: publicEvent.type, - data: JSON.stringify(publicEvent), - }) - ); - }) - .catch(() => { - // A best-effort notification must not affect committed game state. + connection.enqueue(async (currentAuth) => { + const identities = [viewerIdentity]; + if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { + const nextIdentity = await loadViewerIdentity(); + identities.push(nextIdentity); + viewerIdentity = nextIdentity; + } + let refreshGrant: string | undefined; + const publicEvent = toPublicRealtimeEvent(event, identities, () => { + refreshGrant ??= createRealtimeAccessGrant(currentAuth, config.profileName, config.gameTokenSecret); + return refreshGrant; }); + if (!publicEvent || connection.closed) return null; + if (refreshGrant) { + try { + await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName); + } catch { + // An unregistered grant falls back to the scored refresh path. + } + } + return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) }); + }); }); - + connection.onClose(unsubscribe); let heartbeatPending = false; const heartbeat = setInterval(() => { - if (heartbeatPending) return; + if (heartbeatPending || connection.closed) return; heartbeatPending = true; - void turnEngineStatus - .get() - .then((turnEngineRunning) => { - if (closed) return; - sendFrame( - formatSseFrame({ - event: 'ping', - data: JSON.stringify({ turnEngineRunning }), - }) - ); - }) - .finally(() => { + connection.enqueue(async () => { + try { + return formatSseFrame({ + event: 'ping', + data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }), + }); + } finally { heartbeatPending = false; - }); - }, 15000); - - const close = () => { - closed = true; + } + }); + }, 15_000); + connection.onClose(() => { clearInterval(heartbeat); - unsubscribe(); - }; - - request.raw.on('close', close); - request.raw.on('aborted', close); + }); + request.raw.once('aborted', connection.close); + connection.onClose(() => { + request.raw.off('aborted', connection.close); + }); }); app.get('/healthz', async (_request, reply) => { diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 1745bfbd..d479cbea 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -1,3 +1,4 @@ +import { safeTrpcErrorFormatter } from '@sammo-ts/common'; import { randomUUID } from 'node:crypto'; import { initTRPC, TRPCError } from '@trpc/server'; import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import'; @@ -20,7 +21,7 @@ import { import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js'; import { recordGeneralActivity } from './services/generalActivity.js'; -const t = initTRPC.context().create(); +const t = initTRPC.context().create({ errorFormatter: safeTrpcErrorFormatter }); const requireAuthMiddleware = t.middleware(({ ctx, next }) => { if (!ctx.auth) { diff --git a/app/game-api/test/authenticatedRealtimeConnection.test.ts b/app/game-api/test/authenticatedRealtimeConnection.test.ts new file mode 100644 index 00000000..0dda5784 --- /dev/null +++ b/app/game-api/test/authenticatedRealtimeConnection.test.ts @@ -0,0 +1,95 @@ +import { PassThrough } from 'node:stream'; +import type { ServerResponse } from 'node:http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { AuthenticatedRealtimeConnection } from '../src/realtime/authenticatedConnection.js'; + +const payload = (): GameSessionTokenPayload => ({ + version: 1, + sessionId: 'test-session', + profile: 'che:default', + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + user: { + id: 'test-user', + username: 'test', + displayName: 'test', + roles: ['user'], + createdAt: new Date().toISOString(), + }, + sanctions: {}, +}); +const fixture = (resolve: () => Promise, auth = payload()) => { + const response = new PassThrough(); + const frames: string[] = []; + response.on('data', (chunk) => frames.push(String(chunk))); + const connection = new AuthenticatedRealtimeConnection(auth, response as unknown as ServerResponse, resolve); + return { response, frames, connection }; +}; +afterEach(() => vi.useRealTimers()); + +describe('authenticated realtime connection', () => { + it('does not forward an in-flight result after the token is revoked during its work', async () => { + const auth = payload(); + let current: GameSessionTokenPayload | null = auth; + const { connection, frames } = fixture(async () => current, auth); + connection.enqueue(async () => { + current = null; + return 'private invalidation'; + }); + await vi.waitFor(() => expect(connection.closed).toBe(true)); + expect(frames).toEqual([]); + }); + it.each(['sessionId', 'profile'] as const)('rejects a replacement %s even for the same user', async (field) => { + const auth = payload(); + const { connection, frames } = fixture(async () => ({ ...auth, [field]: 'replacement' }), auth); + connection.enqueue(async () => 'private invalidation'); + await vi.waitFor(() => expect(connection.closed).toBe(true)); + expect(frames).toEqual([]); + }); + it('fails closed when authentication storage fails', async () => { + const { connection, frames } = fixture(async () => { + throw new Error('redis unavailable'); + }); + connection.enqueue(async () => 'private invalidation'); + await vi.waitFor(() => expect(connection.closed).toBe(true)); + expect(frames).toEqual([]); + }); + it('ends an idle expired stream and releases subscriptions only once', async () => { + vi.useFakeTimers(); + const auth = payload(); + const { connection } = fixture(async () => auth, auth); + const cleanup = vi.fn(); + connection.onClose(cleanup); + await vi.advanceTimersByTimeAsync(60_001); + expect(connection.closed).toBe(true); + expect(cleanup).toHaveBeenCalledTimes(1); + connection.close(); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + it('bounds queued work while an earlier event is blocked', async () => { + const auth = payload(); + const { connection, frames } = fixture(async () => auth, auth); + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + connection.enqueue(async () => { + await waiting; + return 'private invalidation'; + }); + for (let i = 0; i < 100; i += 1) connection.enqueue(async () => 'private invalidation'); + expect(connection.closed).toBe(true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(frames).toEqual([]); + }); + it('ends a slow socket instead of retaining further frames', async () => { + const auth = payload(); + const { connection, response } = fixture(async () => auth, auth); + response.write = vi.fn(() => false); + connection.enqueue(async () => 'event: ping\ndata: {}\n\n'); + await vi.waitFor(() => expect(connection.closed).toBe(true)); + expect(response.writableEnded).toBe(true); + }); +}); diff --git a/app/game-api/test/httpErrorPrivacy.test.ts b/app/game-api/test/httpErrorPrivacy.test.ts new file mode 100644 index 00000000..1b75cf1c --- /dev/null +++ b/app/game-api/test/httpErrorPrivacy.test.ts @@ -0,0 +1,51 @@ +import fastify from 'fastify'; +import { TRPCError } from '@trpc/server'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { describe, expect, it } from 'vitest'; +import { router as gameRouter, sessionActivityProcedure } from '../src/trpc.js'; + +const internalFailure = () => { + throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') }); +}; +const badInput = () => { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '잘못된 입력입니다.', + cause: new Error('synthetic-private-cause'), + }); +}; + +describe('public HTTP error privacy', () => { + it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => { + const app = fastify(); + await app.register(fastifyTRPCPlugin, { + prefix: '/game', + trpcOptions: { + router: gameRouter({ + internal: sessionActivityProcedure.query(internalFailure), + invalid: sessionActivityProcedure.query(badInput), + }), + createContext: () => ({}), + }, + }); + try { + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + for (const prefix of ['game']) { + for (const procedure of ['internal', 'invalid']) { + const response = await fetch(`${address}/${prefix}/${procedure}`); + const body = (await response.json()) as { + error: { message: string; data: { code: string; stack?: string } }; + }; + expect(response.status).toBe(procedure === 'internal' ? 500 : 400); + expect(body.error.message).toBe( + procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.' + ); + expect(body.error.data.stack).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain('synthetic-'); + } + } + } finally { + await app.close(); + } + }); +}); diff --git a/app/game-api/test/safeHttpLogger.test.ts b/app/game-api/test/safeHttpLogger.test.ts new file mode 100644 index 00000000..2ee0f89b --- /dev/null +++ b/app/game-api/test/safeHttpLogger.test.ts @@ -0,0 +1,46 @@ +import { Writable } from 'node:stream'; +import fastify from 'fastify'; +import { describe, expect, it } from 'vitest'; +import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common'; + +describe('HTTP log credential boundaries', () => { + it('logs static routes/status but no query, unmatched path, header, body, stack or cause secrets', async () => { + let output = ''; + const stream = new Writable({ + write(chunk, _encoding, done) { + output += String(chunk); + done(); + }, + }); + const app = fastify({ logger: { ...safeHttpLoggerOptions, stream } }); + app.setErrorHandler(safeHttpErrorHandler); + app.post('/events', async () => { + throw Object.assign(new Error('synthetic-error-secret', { cause: new Error('synthetic-cause-secret') }), { + code: 'P2002', + detail: 'synthetic-detail-secret', + }); + }); + try { + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + const response = await fetch(`${address}/events?token=synthetic-query-secret`, { + method: 'POST', + headers: { authorization: 'Bearer synthetic-header-secret', 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'synthetic-body-secret' }), + }); + expect(await response.json()).toMatchObject({ message: 'Internal server error' }); + await fetch(`${address}/synthetic-path-secret`); + const logs = output + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(logs.some((entry) => entry.req?.url === '/events')).toBe(true); + expect(logs.some((entry) => entry.res?.statusCode === 500)).toBe(true); + expect(logs.some((entry) => entry.err?.code === 'P2002')).toBe(true); + for (const field of ['query', 'header', 'body', 'error', 'cause', 'detail', 'path']) { + expect(output).not.toContain(`synthetic-${field}-secret`); + } + } finally { + await app.close(); + } + }); +}); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index 2e00a6a5..e869dd79 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,3 +1,4 @@ +import { buildGameEventChannel } from '@sammo-ts/common'; import { createHash } from 'node:crypto'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { asRecord } from '@sammo-ts/common'; @@ -2317,9 +2318,51 @@ integration('game API security over HTTP transport', () => { { decisionId: decisionIds[0]!, ordinal: 0, - steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127 - ? { ...step, sequence, kind: 'DECISION_START', reservedAction: '휴식', effectivePolicy: {"schemaVersion":1,"general":{"priority":["징병"],"flags":{"징병":true,"출병":false}},"nation":{"priority":["천도"],"flags":{"천도":true},"values":{"reqNationGold":4321,"reqNationRice":100,"reqHumanWarUrgentGold":100,"reqHumanWarUrgentRice":100,"reqHumanWarRecommandGold":100,"reqHumanWarRecommandRice":100,"reqHumanDevelGold":100,"reqHumanDevelRice":100,"reqNpcWarGold":100,"reqNpcWarRice":100,"reqNpcDevelGold":100,"reqNpcDevelRice":100,"minimumResourceActionAmount":100,"maximumResourceActionAmount":100,"minNpcWarLeadership":100,"minWarCrew":100,"minNpcRecruitCityPopulation":100,"safeRecruitCityPopulationRatio":100,"properWarTrainAtmos":100,"cureThreshold":100},"combatForce":{"1":[2,3]},"supportForce":[4],"developForce":[5],"secret":"decision-secret"},"secret":"decision-secret"} } - : ({ ...step, sequence })), + steps: Array.from({ length: 128 }, (_, sequence) => + sequence === 127 + ? { + ...step, + sequence, + kind: 'DECISION_START', + reservedAction: '휴식', + effectivePolicy: { + schemaVersion: 1, + general: { priority: ['징병'], flags: { 징병: true, 출병: false } }, + nation: { + priority: ['천도'], + flags: { 천도: true }, + values: { + reqNationGold: 4321, + reqNationRice: 100, + reqHumanWarUrgentGold: 100, + reqHumanWarUrgentRice: 100, + reqHumanWarRecommandGold: 100, + reqHumanWarRecommandRice: 100, + reqHumanDevelGold: 100, + reqHumanDevelRice: 100, + reqNpcWarGold: 100, + reqNpcWarRice: 100, + reqNpcDevelGold: 100, + reqNpcDevelRice: 100, + minimumResourceActionAmount: 100, + maximumResourceActionAmount: 100, + minNpcWarLeadership: 100, + minWarCrew: 100, + minNpcRecruitCityPopulation: 100, + safeRecruitCityPopulationRatio: 100, + properWarTrainAtmos: 100, + cureThreshold: 100, + }, + combatForce: { '1': [2, 3] }, + supportForce: [4], + developForce: [5], + secret: 'decision-secret', + }, + secret: 'decision-secret', + }, + } + : { ...step, sequence } + ), }, { decisionId: decisionIds[0]!, @@ -3530,6 +3573,108 @@ integration('game API security over HTTP transport', () => { } }); + const openRealtime = async (accessToken: string) => { + const abort = new AbortController(); + const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, { + headers: { authorization: `Bearer ${accessToken}` }, + signal: abort.signal, + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const ready = await reader.read(); + expect(new TextDecoder().decode(ready.value)).toContain('event: ready'); + return { abort, reader }; + }; + + it('SSE accepts header auth and rejects query or cross-profile credentials', async () => { + const token = await createAccessToken('sse-query', {}); + const queryOnly = await fetch(`${baseUrl}${server!.config.eventsPath}?token=${encodeURIComponent(token)}`); + expect(queryOnly.status).toBe(401); + const foreign = await accessTokenStore.create({ ...buildPayload('sse-foreign', {}), profile: 'other:default' }); + if (!foreign) throw new Error('SSE foreign token fixture failed'); + try { + const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, { + headers: { authorization: `Bearer ${foreign.accessToken}` }, + }); + expect(response.status).toBe(401); + const stream = await openRealtime(token); + stream.abort.abort(); + } finally { + await accessTokenStore.revoke(token); + await accessTokenStore.revoke(foreign.accessToken); + } + }); + + it.each([ + { label: 'ban', sanctions: { bannedUntil: '2099-01-01T00:00:00.000Z' } }, + { label: 'suspension', sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' } }, + { + label: 'profile game restriction', + sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } }, + }, + ])('SSE rejects $label before opening a stream', async ({ label, sanctions }) => { + const token = await createAccessToken(`sse-${label}`, sanctions); + try { + const response = await fetch(`${baseUrl}${server!.config.eventsPath}`, { + headers: { authorization: `Bearer ${token}` }, + }); + expect(response.status).toBe(401); + expect(response.headers.get('content-type')).not.toBe('text/event-stream'); + } finally { + await accessTokenStore.revoke(token); + } + }); + + it('SSE closes on idle expiry without forwarding another frame', async () => { + const auth = { ...buildPayload('sse-expiry', {}), expiresAt: new Date(Date.now() + 3500).toISOString() }; + const created = await accessTokenStore.create(auth); + if (!created) throw new Error('SSE expiry token fixture failed'); + const stream = await openRealtime(created.accessToken); + try { + expect(await stream.reader.read()).toMatchObject({ done: true }); + expect((await requestTrpc('general.me', { accessToken: created.accessToken })).response.status).toBe(401); + } finally { + stream.abort.abort(); + await accessTokenStore.revoke(created.accessToken); + } + }); + + it('SSE rechecks a revoked token before forwarding a relevant event or refresh grant', async () => { + const accessToken = await createAccessToken('sse-revoke', {}); + const stream = await openRealtime(accessToken); + const channel = server!.config.profileName; + try { + const event = JSON.stringify({ type: 'tournamentChanged' }); + await redis!.client.publish(buildGameEventChannel(channel), event); + const valid = await stream.reader.read(); + expect(new TextDecoder().decode(valid.value)).toContain('refreshGrant'); + expect(await accessTokenStore.revoke(accessToken)).toBe(true); + await redis!.client.publish(buildGameEventChannel(channel), event); + expect(await stream.reader.read()).toMatchObject({ done: true }); + } finally { + stream.abort.abort(); + await accessTokenStore.revoke(accessToken); + } + }); + + it('SSE closes an idle flushed user immediately without waiting for heartbeat', async () => { + const sseUser = `security-http-sse-flush-${process.pid}`; + const created = await accessTokenStore.create(buildPayload('sse-flush', {}, sseUser)); + if (!created) throw new Error('SSE flush fixture failed'); + const stream = await openRealtime(created.accessToken); + try { + await redis!.client.publish( + `${redisPrefix}:flush`, + JSON.stringify({ userId: sseUser, flushedAt: new Date().toISOString() }) + ); + expect(await stream.reader.read()).toMatchObject({ done: true }); + } finally { + stream.abort.abort(); + await accessTokenStore.revoke(created.accessToken); + } + }); + // Flush invalidates every token issued before the user watermark. Keep it // last so this lifecycle assertion cannot invalidate the actor tokens used // by the transport authorization matrix above. diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index e40ccf26..435dda0e 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -880,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt const installRealtimeHarness = async (page: Page) => { await page.addInitScript(() => { - class TestEventSource extends EventTarget { - static latest: TestEventSource | null = null; - static created = 0; - static closed = 0; - readonly url: string; - - constructor(url: string | URL) { - super(); - this.url = url.toString(); - TestEventSource.created += 1; - TestEventSource.latest = this; - queueMicrotask(() => this.dispatchEvent(new Event('open'))); - } - - close() { - if (TestEventSource.latest === this) { - TestEventSource.latest = null; - TestEventSource.closed += 1; - } - } - } - - Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource }); - Object.defineProperty(window, '__emitMainRealtime', { - configurable: true, - value: (type: string, payload: unknown) => { - TestEventSource.latest?.dispatchEvent( - new MessageEvent(type, { - data: JSON.stringify({ - type, - ...(type === 'readModelInvalidated' || type === 'messagesInvalidated' - ? { refreshGrant: 'fixture-realtime-grant' } - : {}), - ...((payload as object) ?? {}), - }), - }) - ); + const originalFetch = window.fetch.bind(window); + let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null; + let created = 0; + let closed = 0; + let authenticated = false; + let credentialInUrl = false; + window.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), location.href); + if (!url.pathname.endsWith('/events')) return originalFetch(input, init); + if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false; + credentialInUrl = url.searchParams.has('token'); + let controller!: ReadableStreamDefaultController; + let ended = false; + const source = { + close: () => { + if (ended) return; + ended = true; + closed += 1; + if (latest === source) latest = null; + controller.close(); + }, + emit: (type: string, payload: unknown) => { + if (ended) return; + controller.enqueue( + new TextEncoder().encode( + `event: ${type}\ndata: ${JSON.stringify({ + type, + ...(type === 'readModelInvalidated' || type === 'messagesInvalidated' + ? { refreshGrant: 'fixture-realtime-grant' } + : {}), + ...((payload as object) ?? {}), + })}\n\n` + ) + ); + }, + }; + const body = new ReadableStream({ + start: (next) => { + controller = next; + }, + cancel: () => { + if (!ended) { + ended = true; + closed += 1; + if (latest === source) latest = null; + } + }, + }); + created += 1; + latest = source; + init?.signal?.addEventListener('abort', source.close, { once: true }); + return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } })); + }; + Object.assign(window, { + __emitMainRealtime: (type: string, payload: unknown) => { + latest?.emit(type, payload); }, - }); - Object.defineProperty(window, '__hasMainRealtime', { - configurable: true, - value: () => TestEventSource.latest !== null, - }); - Object.defineProperty(window, '__mainRealtimeStats', { - configurable: true, - value: () => ({ - active: TestEventSource.latest !== null, - created: TestEventSource.created, - closed: TestEventSource.closed, - }), + __hasMainRealtime: () => latest !== null, + __mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }), }); }); }; @@ -5430,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime()) ) .toBe(true); + expect( + await page.evaluate(() => + ( + window as unknown as { + __mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean }; + } + ).__mainRealtimeStats() + ) + ).toMatchObject({ authenticated: true, credentialInUrl: false }); await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음'); await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); expect(state.dashboardGrantHeaders).toContain(null); + const captureRefreshLayout = async (phase: string): Promise => { + if (!autoRefreshArtifactRoot) return; + await page.evaluate(() => document.fonts.ready); + const layout = await page.evaluate(() => ({ + url: location.href, + viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio }, + cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map( + (element) => { + const style = getComputedStyle(element); + return { + target: element.getAttribute('data-main-target'), + bounds: element.getBoundingClientRect().toJSON(), + fontFamily: style.fontFamily, + fontSize: style.fontSize, + color: style.color, + backgroundColor: style.backgroundColor, + display: style.display, + }; + } + ), + images: Array.from(document.images).map((image) => ({ + src: image.getAttribute('src'), + width: image.naturalWidth, + height: image.naturalHeight, + complete: image.complete, + objectFit: getComputedStyle(image).objectFit, + })), + })); + await Promise.all([ + writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)), + writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()), + ]); + }; + const operationsBeforeTournament = state.operations.length; state.stage = 1; await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true })); @@ -5473,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl }).observe({ entryTypes: ['measure'] }); }); + await captureRefreshLayout('before-refresh'); const callsBeforeRefresh = state.generalMeCalls; const operationsBeforeClockOnly = state.operations.length; await new Promise((resolve) => setTimeout(resolve, 300)); @@ -5616,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)), }; }); + await captureRefreshLayout('after-refresh'); expect(profile.generalMounted).toBe(true); expect(profile.cityMounted).toBe(true); expect(profile.generalMutations).toBeGreaterThan(0); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 16eda4b3..1cc2dd08 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -371,37 +371,57 @@ const installFixture = async ( const installFakeEventSource = async (page: Page) => { await page.addInitScript(() => { - class FakeEventSource extends EventTarget { - static instances: FakeEventSource[] = []; - readonly url: string; - closed = false; - - constructor(url: string | URL) { - super(); - this.url = String(url); - FakeEventSource.instances.push(this); - queueMicrotask(() => { - if (!this.closed) this.dispatchEvent(new Event('open')); - }); - } - - close() { - this.closed = true; - } - - emit(type: string, payload: unknown) { - if (this.closed) return; - this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) })); - } - } - - Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource }); + const originalFetch = window.fetch.bind(window); + const sources: Array<{ + url: string; + closed: boolean; + authenticated: boolean; + emit: (type: string, payload: unknown) => void; + }> = []; + window.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input), location.href); + if (!url.pathname.endsWith('/events')) return originalFetch(input, init); + if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')); + let controller!: ReadableStreamDefaultController; + const source = { + url: url.href, + closed: false, + authenticated: new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false, + emit: (type: string, payload: unknown) => { + if (!source.closed) + controller.enqueue( + new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`) + ); + }, + }; + const body = new ReadableStream({ + start: (next) => { + controller = next; + }, + cancel: () => { + source.closed = true; + }, + }); + sources.push(source); + init?.signal?.addEventListener( + 'abort', + () => { + if (!source.closed) { + source.closed = true; + controller.close(); + } + }, + { once: true } + ); + return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } })); + }; Object.assign(window, { - __tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length, - __tournamentEventSourceUrls: () => - FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url), + __tournamentEventSourceCount: () => sources.filter((source) => !source.closed).length, + __tournamentEventSourceUrls: () => sources.filter((source) => !source.closed).map((source) => source.url), + __tournamentEventSourceAuthenticated: () => + sources.filter((source) => !source.closed).every((source) => source.authenticated), __emitTournamentEvent: (type: string, payload: unknown) => { - for (const source of FakeEventSource.instances) source.emit(type, payload); + for (const source of sources) source.emit(type, payload); }, }); }); @@ -885,6 +905,18 @@ test('betting realtime refresh is shared across tabs and preserves local interac ).flat(); expect(activeSourceUrls).toHaveLength(1); expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament'); + expect(new URL(activeSourceUrls[0]!).searchParams.has('token')).toBe(false); + for (const candidate of [page, follower]) { + expect( + await candidate.evaluate(() => + ( + window as unknown as { + __tournamentEventSourceAuthenticated: () => boolean; + } + ).__tournamentEventSourceAuthenticated() + ) + ).toBe(true); + } const before = { snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 020ddced..27173c89 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -49,6 +49,7 @@ "chart.js": "4.5.1", "date-fns": "^4.1.0", "es-toolkit": "^1.43.0", + "eventsource": "5.1.2", "mitt": "^3.0.1", "pinia": "^3.0.4", "tippy.js": "6.3.7", diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 2c7f2678..fb3e938d 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -1,3 +1,4 @@ +import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource'; import { receiveClockEngineState } from '../composables/useClockDisplay'; import { computed, ref, toRaw, watch } from 'vue'; import { defineStore } from 'pinia'; @@ -1139,16 +1140,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; - let realtimeSource: EventSource | null = null; + let realtimeSource: AuthenticatedEventSource | null = null; let realtimeToken: string | null = null; let visibilityListenerInstalled = false; const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_')); - const buildRealtimeUrl = (token: string): string => { + const buildRealtimeUrl = (): string => { const base = gameFrontendRuntimeConfig.gameSseUrl; const url = new URL(base, window.location.origin); - url.searchParams.set('token', token); return url.toString(); }; @@ -1284,15 +1284,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { realtimeToken = token; realtimeStatus.value = 'idle'; - const source = new EventSource(buildRealtimeUrl(token)); + let needsRecovery = false; + const source = createAuthenticatedEventSource(buildRealtimeUrl(), token); realtimeSource = source; source.addEventListener('open', () => { markGameServerContact(); realtimeStatus.value = 'connected'; realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' }); + if (needsRecovery) { + needsRecovery = false; + realtimeRefreshQueue.request(); + } }); - source.addEventListener('error', () => { + source.addEventListener('error', (event) => { + if (event.code === 401) void session.refreshGeneralStatus(); + needsRecovery = true; realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused'; realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); }); diff --git a/app/game-frontend/src/stores/tournamentPages.ts b/app/game-frontend/src/stores/tournamentPages.ts index 18341c16..e0a00cee 100644 --- a/app/game-frontend/src/stores/tournamentPages.ts +++ b/app/game-frontend/src/stores/tournamentPages.ts @@ -1,3 +1,4 @@ +import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource'; import { defineStore } from 'pinia'; import { ref, watch } from 'vue'; import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common'; @@ -49,7 +50,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => { const realtimeStatus = ref<'idle' | 'connected'>('idle'); let activeConsumers = 0; - let realtimeSource: EventSource | null = null; + let realtimeSource: AuthenticatedEventSource | null = null; let realtimeToken: string | null = null; let realtimeCoordinator: BroadcastTabCoordinator | null = null; let realtimeCoordinatorScope: string | null = null; @@ -136,10 +137,9 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => { if (!(await session.exchangeGatewayToken())) return null; return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null; }; - const buildRealtimeUrl = (token: string): string => { + const buildRealtimeUrl = (): string => { const base = gameFrontendRuntimeConfig.gameSseUrl; const url = new URL(base, window.location.origin); - url.searchParams.set('token', token); url.searchParams.set('scope', 'tournament'); return url.toString(); }; @@ -181,7 +181,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => { if (realtimeSource && realtimeToken === token) return; closeRealtimeSource(); realtimeToken = token; - const source = new EventSource(buildRealtimeUrl(token)); + const source = createAuthenticatedEventSource(buildRealtimeUrl(), token); realtimeSource = source; source.addEventListener('open', () => { realtimeStatus.value = 'connected'; @@ -196,7 +196,8 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => { }); } }); - source.addEventListener('error', () => { + source.addEventListener('error', (event) => { + if (event.code === 401) void session.refreshGeneralStatus(); needsRecovery = true; realtimeStatus.value = 'idle'; realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); diff --git a/app/game-frontend/src/utils/authenticatedEventSource.ts b/app/game-frontend/src/utils/authenticatedEventSource.ts new file mode 100644 index 00000000..fad692a3 --- /dev/null +++ b/app/game-frontend/src/utils/authenticatedEventSource.ts @@ -0,0 +1,26 @@ +import { EventSource } from 'eventsource'; + +export type AuthenticatedEventSource = EventSource; + +/** Keep the access token out of browser/proxy URLs and ambient cookies. */ +export const createAuthenticatedEventSource = (url: string, accessToken: string): EventSource => { + const destination = new URL(url); + if (destination.searchParams.has('token') || destination.username || destination.password) { + throw new Error('Realtime credentials must use the authorization header.'); + } + return new EventSource(destination, { + maxBufferSize: 64 * 1024, + fetch: (input, init) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${accessToken}`); + return fetch(input, { + ...init, + headers, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + referrerPolicy: 'no-referrer', + }); + }, + }); +}; diff --git a/app/game-frontend/test/authenticatedEventSource.test.ts b/app/game-frontend/test/authenticatedEventSource.test.ts new file mode 100644 index 00000000..c23ee07c --- /dev/null +++ b/app/game-frontend/test/authenticatedEventSource.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { createServer, type Server } from 'node:http'; +import test from 'node:test'; +import { createAuthenticatedEventSource } from '../src/utils/authenticatedEventSource.ts'; + +const listen = (server: Server): Promise => + new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing fixture listener'); + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +const stop = (server: Server): Promise => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); + +void test( + 'reconnects with header auth, omits ambient credentials and preserves named UTF-8 SSE events', + { timeout: 5_000 }, + async () => { + const requests: Array<{ url: string; authorization: string | undefined; cookie: string | undefined }> = []; + const server = createServer((request, response) => { + requests.push({ + url: request.url!, + authorization: request.headers.authorization, + cookie: request.headers.cookie, + }); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write( + `retry: 20\nevent: update\ndata: ${JSON.stringify({ value: '갱신', attempt: requests.length })}\n\n` + ); + if (requests.length === 1) response.end(); + }); + const origin = await listen(server); + const source = createAuthenticatedEventSource(`${origin}/events?scope=tournament`, 'ga_synthetic-fixture'); + try { + const events: unknown[] = []; + await new Promise((resolve) => + source.addEventListener('update', (event) => { + events.push(JSON.parse(event.data)); + if (events.length === 2) resolve(); + }) + ); + assert.deepEqual(events, [ + { value: '갱신', attempt: 1 }, + { value: '갱신', attempt: 2 }, + ]); + assert.deepEqual( + requests, + Array.from({ length: 2 }, () => ({ + url: '/events?scope=tournament', + authorization: 'Bearer ga_synthetic-fixture', + cookie: undefined, + })) + ); + } finally { + source.close(); + await stop(server); + } + } +); + +void test('401 is terminal and redirects never forward the authorization header', { timeout: 5_000 }, async () => { + let redirectedRequests = 0; + const target = createServer((_req, res) => { + redirectedRequests += 1; + res.writeHead(200); + res.end(); + }); + const targetOrigin = await listen(target); + const server = createServer((req, res) => { + res.writeHead(req.url === '/unauthorized' ? 401 : 302, { location: targetOrigin }); + res.end(); + }); + const origin = await listen(server); + try { + for (const endpoint of ['unauthorized', 'redirect']) { + const source = createAuthenticatedEventSource(`${origin}/${endpoint}`, 'ga_synthetic-fixture'); + try { + const code = await new Promise((resolve) => + source.addEventListener('error', (event) => resolve(event.code)) + ); + if (endpoint === 'unauthorized') { + assert.equal(code, 401); + assert.equal(source.readyState, source.CLOSED); + } + } finally { + source.close(); + } + } + assert.equal(redirectedRequests, 0); + } finally { + await stop(server); + await stop(target); + } +}); + +void test('rejects configured query or URL credentials before connecting', () => { + assert.throws(() => createAuthenticatedEventSource('http://localhost/events?token=synthetic', 'ga_synthetic')); + assert.throws(() => createAuthenticatedEventSource('http://user:synthetic@localhost/events', 'ga_synthetic')); +}); diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 5e3a08cc..04273bcc 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -1,3 +1,4 @@ +import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common'; import fastify, { type FastifyRequest } from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; @@ -89,9 +90,10 @@ export const createGatewayApiServer = async () => { config.defaultNavigationConfigFile ); const app = fastify({ - logger: true, + logger: safeHttpLoggerOptions, routerOptions: gatewayFastifyRouterOptions, }); + app.setErrorHandler(safeHttpErrorHandler); const webPush = new WebPushCoordinator( postgres.prisma as GatewayPrismaClient, { diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts index 315a247b..739369e5 100644 --- a/app/gateway-api/src/trpc.ts +++ b/app/gateway-api/src/trpc.ts @@ -1,8 +1,9 @@ +import { safeTrpcErrorFormatter } from '@sammo-ts/common'; import { initTRPC } from '@trpc/server'; import type { GatewayApiContext } from './context.js'; -const t = initTRPC.context().create(); +const t = initTRPC.context().create({ errorFormatter: safeTrpcErrorFormatter }); export const router = t.router; export const procedure = t.procedure; diff --git a/app/gateway-api/test/httpErrorPrivacy.test.ts b/app/gateway-api/test/httpErrorPrivacy.test.ts new file mode 100644 index 00000000..4d20f567 --- /dev/null +++ b/app/gateway-api/test/httpErrorPrivacy.test.ts @@ -0,0 +1,51 @@ +import fastify from 'fastify'; +import { TRPCError } from '@trpc/server'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { describe, expect, it } from 'vitest'; +import { router as gatewayRouter, procedure as gatewayProcedure } from '../src/trpc.js'; + +const internalFailure = () => { + throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') }); +}; +const badInput = () => { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '잘못된 입력입니다.', + cause: new Error('synthetic-private-cause'), + }); +}; + +describe('public HTTP error privacy', () => { + it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => { + const app = fastify(); + await app.register(fastifyTRPCPlugin, { + prefix: '/gateway', + trpcOptions: { + router: gatewayRouter({ + internal: gatewayProcedure.query(internalFailure), + invalid: gatewayProcedure.query(badInput), + }), + createContext: () => ({}), + }, + }); + try { + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + for (const prefix of ['gateway']) { + for (const procedure of ['internal', 'invalid']) { + const response = await fetch(`${address}/${prefix}/${procedure}`); + const body = (await response.json()) as { + error: { message: string; data: { code: string; stack?: string } }; + }; + expect(response.status).toBe(procedure === 'internal' ? 500 : 400); + expect(body.error.message).toBe( + procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.' + ); + expect(body.error.data.stack).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain('synthetic-'); + } + } + } finally { + await app.close(); + } + }); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index 07178123..32b1ed08 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -540,3 +540,29 @@ schema head·component 검사는 그대로 수행합니다. Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부 Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다. 운영 배포에서는 위 확인 목록을 실제 서비스 경로에서 다시 수행해 주세요. + +## SSE 인증 전송과 API 진단 보안 (2026-10-09) + +게임 frontend의 SSE는 `eventsource@5.1.2`의 fetch adapter로 +`Authorization: Bearer`를 전달한다. URL에는 `scope=tournament` 같은 구독 선택만 +남기며 token, URL credential, ambient cookie, redirect 전달을 허용하지 않는다. +기존 native EventSource를 사용하는 열린 탭은 새 game API에서 401을 받으므로 +배포 후 새로고침해야 한다. 독립 배포라면 새 frontend를 먼저 게시하고 game API를 +갱신한다. 새 frontend의 헤더 인증은 이전 game API도 지원한다. API만 먼저 +갱신하면 구형 탭의 실시간 갱신이 중단될 수 있다. + +SSE는 최초 요청과 매 frame 작업 전·전송 전에 Redis token, flush watermark, +user/session/profile과 게임 접근 제재를 확인한다. 만료 타이머와 Gateway flush가 +대기 중인 연결을 닫고 Redis 장애는 fail closed로 처리한다. 연결별 대기 작업은 +32개, frame과 socket buffer는 64 KiB로 제한하고 slow consumer를 닫는다. +재연결 뒤 메인은 기존 갱신 큐로 snapshot을 복구한다. 게임 계산·RNG·DB schema와 +read-model invalidation/grant의 사용자 범위는 변경하지 않는다. 연결 수 전체의 +상한이나 온라인 인증 rate limit까지 구현한 것으로 해석하지 않는다. + +Game/Gateway HTTP logger는 raw URL/query/header/body와 error message/cause/stack +대신 static route, request ID, status와 허용된 error code만 남긴다. Fastify의 +기본 404/오류 message도 정화한다. 공개 tRPC 오류의 stack을 제거하고 내부 500의 +message는 일반 문구로 치환한다. 4xx의 사용자용 검증/권한 문구는 유지한다. +이 정책은 HTTP API 로그의 범위이며 release builder의 관리자 build log를 +일괄 숨기지 않는다. 장애 조사 시 request ID·route·status·code와 별도 격리 재현을 +사용하며 운영 HTTP 로그에 credential이나 raw error payload를 다시 추가하지 않는다. diff --git a/packages/common/src/http/errorPrivacy.ts b/packages/common/src/http/errorPrivacy.ts new file mode 100644 index 00000000..50b091bc --- /dev/null +++ b/packages/common/src/http/errorPrivacy.ts @@ -0,0 +1,31 @@ +/** Preserve public validation/permission messages while hiding internal causes and stacks. */ +export const safeTrpcErrorFormatter = ({ + shape, +}: { + shape: T; +}): T => ({ + ...shape, + message: shape.data.code === 'INTERNAL_SERVER_ERROR' ? 'Internal server error' : shape.message, + data: { ...shape.data, stack: undefined }, +}); + +export const safeHttpErrorHandler = ( + error: unknown, + request: { log: { error: (context: object, message: string) => void } }, + reply: { code: (status: number) => { send: (body: object) => unknown } } +): void => { + const value = error && typeof error === 'object' ? (error as { statusCode?: number; message?: string }) : {}; + const status = + typeof value.statusCode === 'number' && + Number.isInteger(value.statusCode) && + value.statusCode >= 400 && + value.statusCode < 500 + ? value.statusCode + : 500; + request.log.error({ err: error }, 'Request handling failed'); + reply.code(status).send({ + statusCode: status, + error: status >= 500 ? 'Internal Server Error' : 'Request Error', + message: status >= 500 ? 'Internal server error' : (value.message ?? 'Request failed'), + }); +}; diff --git a/packages/common/src/http/safeLogger.ts b/packages/common/src/http/safeLogger.ts new file mode 100644 index 00000000..6b9ea512 --- /dev/null +++ b/packages/common/src/http/safeLogger.ts @@ -0,0 +1,75 @@ +const errorCodes = /^(?:P\d{4}|FST_ERR_[A-Z_]+|ERR_[A-Z_]+|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|INTERNAL_SERVER_ERROR)$/; + +const errorSummary = ( + error: unknown +): { type: string; message: string; stack: string; code?: string; statusCode?: number } => { + const value = error && typeof error === 'object' ? (error as Record) : {}; + const code = + typeof value.code === 'string' && value.code.length < 80 && errorCodes.test(value.code) + ? value.code + : undefined; + const statusCode = + typeof value.statusCode === 'number' && value.statusCode >= 400 && value.statusCode <= 599 + ? value.statusCode + : undefined; + return { + type: 'RequestError', + message: 'Request handling failed', + stack: '', + ...(code ? { code } : {}), + ...(statusCode ? { statusCode } : {}), + }; +}; + +/** HTTP diagnostics carry static route/status/code, never credentials or error payloads. */ +export const safeHttpLoggerOptions = { + serializers: { + req: (request: { method?: string; routeOptions?: { url?: string } }) => ({ + method: request.method, + // URLs can carry OAuth/session/input values, including unmatched path segments. + url: request.routeOptions?.url ?? '[unmatched]', + }), + res: (response: { statusCode?: number }) => ({ statusCode: response.statusCode }), + err: errorSummary, + }, + redact: { + paths: [ + 'authorization', + 'cookie', + 'password', + 'sessionToken', + 'accessToken', + 'token', + 'secret', + 'body', + 'headers', + 'req.headers', + 'req.body', + 'res.headers', + ], + remove: true, + }, + hooks: { + logMethod(args: unknown[], method: (...values: unknown[]) => void): void { + const first = args[0]; + const error = + first instanceof Error + ? first + : first && typeof first === 'object' && 'err' in first + ? first.err + : undefined; + if (error !== undefined) { + // Fastify also copies err.message into msg; serializers alone do not protect it. + method.apply(this, [{ err: errorSummary(error) }, 'Request handling failed']); + } else { + // Fastify's default 404 handler also embeds the raw URL in a message. + method.apply( + this, + args.map((value) => + typeof value === 'string' && /^Route .+ not found$/.test(value) ? 'Route not found' : value + ) + ); + } + }, + }, +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 6a2e9d0f..4b7616c3 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -31,6 +31,8 @@ export * from './gateway/profileStatus.js'; export * from './gateway/runtimeDiagnostics.js'; export * from './game/accessPenalty.js'; export * from './http/trpcTransport.js'; +export * from './http/safeLogger.js'; +export * from './http/errorPrivacy.js'; export * from './webPush/types.js'; export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44aa10b1..f0e652cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -381,6 +381,9 @@ importers: es-toolkit: specifier: ^1.43.0 version: 1.51.0 + eventsource: + specifier: 5.1.2 + version: 5.1.2 mitt: specifier: ^3.0.1 version: 3.0.1 @@ -3750,6 +3753,14 @@ packages: eventemitter2@6.4.9: resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventsource-parser@4.1.1: + resolution: {integrity: sha512-zaWNqeSLeyzEAAt2Q6aDBRDRk/VnPmsKqvToyGvv6aahvLv71A4Jw7jG93Hckf11zXurbqgsffvpSgKPoeU1Qw==} + engines: {node: '>=22.12'} + + eventsource@5.1.2: + resolution: {integrity: sha512-VJnwqqp+fOT3WDkjnUy49Zuyh48+kH0JdJApZkslrRQyx35zIZJeJYJl0+a+LrTzh5TFv6MdmXnBDv71UXFyWQ==} + engines: {node: '>=22.12.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -7879,6 +7890,12 @@ snapshots: eventemitter2@6.4.9: {} + eventsource-parser@4.1.1: {} + + eventsource@5.1.2: + dependencies: + eventsource-parser: 4.1.1 + expect-type@1.4.0: {} exsolve@1.0.8: {}