SSE 헤더 인증과 연결 수명 검증 및 HTTP 오류 정보 보호

This commit is contained in:
2026-10-09 02:35:45 +09:00
parent 87f810a878
commit 9dceaf8e4e
22 changed files with 1023 additions and 170 deletions
@@ -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<typeof setTimeout>;
private cleanup = new Set<() => void>();
constructor(
readonly auth: GameSessionTokenPayload,
private readonly response: ServerResponse,
private readonly resolveAuth: () => Promise<GameSessionTokenPayload | null>
) {
// 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<GameSessionTokenPayload | null> {
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<string | null>): 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();
};
}
+57 -59
View File
@@ -1,3 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -32,6 +33,8 @@ import { appRouter } from './router.js';
import { buildBattleSimQueueKeys } from './battleSim/keys.js'; import { buildBattleSimQueueKeys } from './battleSim/keys.js';
import { RedisBattleSimTransport } from './battleSim/redisTransport.js'; import { RedisBattleSimTransport } from './battleSim/redisTransport.js';
import { RedisRealtimeEventHub } from './realtime/eventHub.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 { formatSseFrame } from './realtime/sse.js';
import { import {
shouldForwardRealtimeEvent, shouldForwardRealtimeEvent,
@@ -99,11 +102,16 @@ export const createGameApiServer = async () => {
imageUploadSecret imageUploadSecret
); );
const app = fastify({ const app = fastify({
logger: true, logger: safeHttpLoggerOptions,
routerOptions: { routerOptions: {
maxParamLength: 2048, maxParamLength: 2048,
}, },
}); });
app.setErrorHandler(safeHttpErrorHandler);
const realtimeConnections = new Set<AuthenticatedRealtimeConnection>();
app.addHook('preClose', async () => {
for (const connection of realtimeConnections) connection.close();
});
const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile })); const postgres = createGamePostgresConnector(resolvePostgresConfigFromEnv({ schema: config.profile }));
const redis = createRedisConnector(resolveRedisConfigFromEnv()); const redis = createRedisConnector(resolveRedisConfigFromEnv());
@@ -150,6 +158,12 @@ export const createGameApiServer = async () => {
config.flushChannel, config.flushChannel,
flushStore, flushStore,
async (event) => { 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)]); await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]);
}, },
(error, event) => { (error, event) => {
@@ -299,14 +313,24 @@ export const createGameApiServer = async () => {
}, },
}); });
const resolveRealtimeAuth = async (token: string | null): Promise<GameSessionTokenPayload | null> => {
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) => { app.get(config.eventsPath, async (request, reply) => {
const query = request.query as { token?: string; scope?: string }; const query = request.query as { token?: string; scope?: string };
const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard'; const subscriptionScope = query.scope === 'tournament' ? 'tournament' : 'dashboard';
const tokenFromHeader = extractBearerToken(request.headers.authorization); const tokenFromHeader = extractBearerToken(request.headers.authorization);
const tokenFromQuery = typeof query.token === 'string' ? query.token : null; const auth = query.token === undefined ? await resolveRealtimeAuth(tokenFromHeader) : null;
const auth = await resolveAuthFromToken(tokenFromHeader ?? tokenFromQuery, accessTokenStore, flushStore);
if (!auth) { if (!auth || auth.profile !== config.profileName) {
await reply.status(401).send({ ok: false, error: 'unauthorized' }); await reply.status(401).send({ ok: false, error: 'unauthorized' });
return; return;
} }
@@ -351,28 +375,17 @@ export const createGameApiServer = async () => {
reply.raw.setTimeout?.(0); reply.raw.setTimeout?.(0);
reply.raw.flushHeaders?.(); reply.raw.flushHeaders?.();
const sendFrame = (payload: string) => { const connection = new AuthenticatedRealtimeConnection(auth, reply.raw, () =>
try { resolveRealtimeAuth(tokenFromHeader)
reply.raw.write(payload);
} catch {
return;
}
};
sendFrame(
formatSseFrame({
event: 'ready',
data: '{}',
})
); );
realtimeConnections.add(connection);
let closed = false; connection.onClose(() => {
let eventQueue = Promise.resolve(); realtimeConnections.delete(connection);
});
connection.enqueue(async () => formatSseFrame({ event: 'ready', data: '{}' }));
const unsubscribe = realtimeHub.subscribe((event) => { const unsubscribe = realtimeHub.subscribe((event) => {
if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return; if (!shouldForwardRealtimeEvent(event, subscriptionScope)) return;
eventQueue = eventQueue connection.enqueue(async (currentAuth) => {
.then(async () => {
if (closed) return;
const identities = [viewerIdentity]; const identities = [viewerIdentity];
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) { if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
const nextIdentity = await loadViewerIdentity(); const nextIdentity = await loadViewerIdentity();
@@ -381,58 +394,43 @@ export const createGameApiServer = async () => {
} }
let refreshGrant: string | undefined; let refreshGrant: string | undefined;
const publicEvent = toPublicRealtimeEvent(event, identities, () => { const publicEvent = toPublicRealtimeEvent(event, identities, () => {
refreshGrant ??= createRealtimeAccessGrant(auth, config.profileName, config.gameTokenSecret); refreshGrant ??= createRealtimeAccessGrant(currentAuth, config.profileName, config.gameTokenSecret);
return refreshGrant; return refreshGrant;
}); });
if (!publicEvent || closed) return; if (!publicEvent || connection.closed) return null;
if (refreshGrant) { if (refreshGrant) {
try { try {
await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName); await registerRealtimeAccessGrant(redis.client, refreshGrant, config.profileName);
} catch { } catch {
// Preserve the invalidation. An unregistered grant safely falls back // An unregistered grant falls back to the scored refresh path.
// to the normal scored refresh path.
} }
} }
sendFrame( return formatSseFrame({ event: publicEvent.type, data: JSON.stringify(publicEvent) });
formatSseFrame({
event: publicEvent.type,
data: JSON.stringify(publicEvent),
})
);
})
.catch(() => {
// A best-effort notification must not affect committed game state.
}); });
}); });
connection.onClose(unsubscribe);
let heartbeatPending = false; let heartbeatPending = false;
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
if (heartbeatPending) return; if (heartbeatPending || connection.closed) return;
heartbeatPending = true; heartbeatPending = true;
void turnEngineStatus connection.enqueue(async () => {
.get() try {
.then((turnEngineRunning) => { return formatSseFrame({
if (closed) return;
sendFrame(
formatSseFrame({
event: 'ping', event: 'ping',
data: JSON.stringify({ turnEngineRunning }), data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
})
);
})
.finally(() => {
heartbeatPending = false;
}); });
}, 15000); } finally {
heartbeatPending = false;
const close = () => { }
closed = true; });
}, 15_000);
connection.onClose(() => {
clearInterval(heartbeat); clearInterval(heartbeat);
unsubscribe(); });
}; request.raw.once('aborted', connection.close);
connection.onClose(() => {
request.raw.on('close', close); request.raw.off('aborted', connection.close);
request.raw.on('aborted', close); });
}); });
app.get('/healthz', async (_request, reply) => { app.get('/healthz', async (_request, reply) => {
+2 -1
View File
@@ -1,3 +1,4 @@
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { initTRPC, TRPCError } from '@trpc/server'; import { initTRPC, TRPCError } from '@trpc/server';
import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import'; import { middlewareMarker } from '@trpc/server/unstable-core-do-not-import';
@@ -20,7 +21,7 @@ import {
import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js'; import { getDeferredGeneralAccessLimit } from './services/deferredGeneralAccess.js';
import { recordGeneralActivity } from './services/generalActivity.js'; import { recordGeneralActivity } from './services/generalActivity.js';
const t = initTRPC.context<GameApiContext>().create(); const t = initTRPC.context<GameApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
const requireAuthMiddleware = t.middleware(({ ctx, next }) => { const requireAuthMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.auth) { if (!ctx.auth) {
@@ -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<GameSessionTokenPayload | null>, 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<void>((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);
});
});
@@ -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();
}
});
});
+46
View File
@@ -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();
}
});
});
@@ -1,3 +1,4 @@
import { buildGameEventChannel } from '@sammo-ts/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
@@ -2317,9 +2318,51 @@ integration('game API security over HTTP transport', () => {
{ {
decisionId: decisionIds[0]!, decisionId: decisionIds[0]!,
ordinal: 0, ordinal: 0,
steps: Array.from({ length: 128 }, (_, sequence) => sequence === 127 steps: Array.from({ length: 128 }, (_, sequence) =>
? { ...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"} } sequence === 127
: ({ ...step, sequence })), ? {
...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]!, 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 // Flush invalidates every token issued before the user watermark. Keep it
// last so this lifecycle assertion cannot invalidate the actor tokens used // last so this lifecycle assertion cannot invalidate the actor tokens used
// by the transport authorization matrix above. // by the transport authorization matrix above.
+97 -42
View File
@@ -880,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
const installRealtimeHarness = async (page: Page) => { const installRealtimeHarness = async (page: Page) => {
await page.addInitScript(() => { await page.addInitScript(() => {
class TestEventSource extends EventTarget { const originalFetch = window.fetch.bind(window);
static latest: TestEventSource | null = null; let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
static created = 0; let created = 0;
static closed = 0; let closed = 0;
readonly url: string; let authenticated = false;
let credentialInUrl = false;
constructor(url: string | URL) { window.fetch = (input, init) => {
super(); const url = new URL(input instanceof Request ? input.url : String(input), location.href);
this.url = url.toString(); if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
TestEventSource.created += 1; if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
TestEventSource.latest = this; authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
queueMicrotask(() => this.dispatchEvent(new Event('open'))); credentialInUrl = url.searchParams.has('token');
} let controller!: ReadableStreamDefaultController<Uint8Array>;
let ended = false;
close() { const source = {
if (TestEventSource.latest === this) { close: () => {
TestEventSource.latest = null; if (ended) return;
TestEventSource.closed += 1; ended = true;
} closed += 1;
} if (latest === source) latest = null;
} controller.close();
},
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource }); emit: (type: string, payload: unknown) => {
Object.defineProperty(window, '__emitMainRealtime', { if (ended) return;
configurable: true, controller.enqueue(
value: (type: string, payload: unknown) => { new TextEncoder().encode(
TestEventSource.latest?.dispatchEvent( `event: ${type}\ndata: ${JSON.stringify({
new MessageEvent(type, {
data: JSON.stringify({
type, type,
...(type === 'readModelInvalidated' || type === 'messagesInvalidated' ...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
? { refreshGrant: 'fixture-realtime-grant' } ? { refreshGrant: 'fixture-realtime-grant' }
: {}), : {}),
...((payload as object) ?? {}), ...((payload as object) ?? {}),
}), })}\n\n`
}) )
); );
}, },
};
const body = new ReadableStream<Uint8Array>({
start: (next) => {
controller = next;
},
cancel: () => {
if (!ended) {
ended = true;
closed += 1;
if (latest === source) latest = null;
}
},
}); });
Object.defineProperty(window, '__hasMainRealtime', { created += 1;
configurable: true, latest = source;
value: () => TestEventSource.latest !== null, init?.signal?.addEventListener('abort', source.close, { once: true });
}); return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
Object.defineProperty(window, '__mainRealtimeStats', { };
configurable: true, Object.assign(window, {
value: () => ({ __emitMainRealtime: (type: string, payload: unknown) => {
active: TestEventSource.latest !== null, latest?.emit(type, payload);
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()) page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
) )
.toBe(true); .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('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u); await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
expect(state.dashboardGrantHeaders).toContain(null); expect(state.dashboardGrantHeaders).toContain(null);
const captureRefreshLayout = async (phase: string): Promise<void> => {
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; const operationsBeforeTournament = state.operations.length;
state.stage = 1; state.stage = 1;
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true })); 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'] }); }).observe({ entryTypes: ['measure'] });
}); });
await captureRefreshLayout('before-refresh');
const callsBeforeRefresh = state.generalMeCalls; const callsBeforeRefresh = state.generalMeCalls;
const operationsBeforeClockOnly = state.operations.length; const operationsBeforeClockOnly = state.operations.length;
await new Promise((resolve) => setTimeout(resolve, 300)); 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)), vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
}; };
}); });
await captureRefreshLayout('after-refresh');
expect(profile.generalMounted).toBe(true); expect(profile.generalMounted).toBe(true);
expect(profile.cityMounted).toBe(true); expect(profile.cityMounted).toBe(true);
expect(profile.generalMutations).toBeGreaterThan(0); expect(profile.generalMutations).toBeGreaterThan(0);
+59 -27
View File
@@ -371,37 +371,57 @@ const installFixture = async (
const installFakeEventSource = async (page: Page) => { const installFakeEventSource = async (page: Page) => {
await page.addInitScript(() => { await page.addInitScript(() => {
class FakeEventSource extends EventTarget { const originalFetch = window.fetch.bind(window);
static instances: FakeEventSource[] = []; const sources: Array<{
readonly url: string; url: string;
closed = false; closed: boolean;
authenticated: boolean;
constructor(url: string | URL) { emit: (type: string, payload: unknown) => void;
super(); }> = [];
this.url = String(url); window.fetch = (input, init) => {
FakeEventSource.instances.push(this); const url = new URL(input instanceof Request ? input.url : String(input), location.href);
queueMicrotask(() => { if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
if (!this.closed) this.dispatchEvent(new Event('open')); if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
let controller!: ReadableStreamDefaultController<Uint8Array>;
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<Uint8Array>({
start: (next) => {
controller = next;
},
cancel: () => {
source.closed = true;
},
}); });
sources.push(source);
init?.signal?.addEventListener(
'abort',
() => {
if (!source.closed) {
source.closed = true;
controller.close();
} }
},
close() { { once: true }
this.closed = true; );
} return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
};
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 });
Object.assign(window, { Object.assign(window, {
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length, __tournamentEventSourceCount: () => sources.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () => __tournamentEventSourceUrls: () => sources.filter((source) => !source.closed).map((source) => source.url),
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url), __tournamentEventSourceAuthenticated: () =>
sources.filter((source) => !source.closed).every((source) => source.authenticated),
__emitTournamentEvent: (type: string, payload: unknown) => { __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(); ).flat();
expect(activeSourceUrls).toHaveLength(1); expect(activeSourceUrls).toHaveLength(1);
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament'); 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 = { const before = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length, snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
+1
View File
@@ -49,6 +49,7 @@
"chart.js": "4.5.1", "chart.js": "4.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"es-toolkit": "^1.43.0", "es-toolkit": "^1.43.0",
"eventsource": "5.1.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",
+12 -5
View File
@@ -1,3 +1,4 @@
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
import { receiveClockEngineState } from '../composables/useClockDisplay'; import { receiveClockEngineState } from '../composables/useClockDisplay';
import { computed, ref, toRaw, watch } from 'vue'; import { computed, ref, toRaw, watch } from 'vue';
import { defineStore } from 'pinia'; 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 realtimeToken: string | null = null;
let visibilityListenerInstalled = false; let visibilityListenerInstalled = false;
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_')); const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
const buildRealtimeUrl = (token: string): string => { const buildRealtimeUrl = (): string => {
const base = gameFrontendRuntimeConfig.gameSseUrl; const base = gameFrontendRuntimeConfig.gameSseUrl;
const url = new URL(base, window.location.origin); const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
return url.toString(); return url.toString();
}; };
@@ -1284,15 +1284,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeToken = token; realtimeToken = token;
realtimeStatus.value = 'idle'; realtimeStatus.value = 'idle';
const source = new EventSource(buildRealtimeUrl(token)); let needsRecovery = false;
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
realtimeSource = source; realtimeSource = source;
source.addEventListener('open', () => { source.addEventListener('open', () => {
markGameServerContact(); markGameServerContact();
realtimeStatus.value = 'connected'; realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: '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'; realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
}); });
@@ -1,3 +1,4 @@
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common'; import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
@@ -49,7 +50,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
const realtimeStatus = ref<'idle' | 'connected'>('idle'); const realtimeStatus = ref<'idle' | 'connected'>('idle');
let activeConsumers = 0; let activeConsumers = 0;
let realtimeSource: EventSource | null = null; let realtimeSource: AuthenticatedEventSource | null = null;
let realtimeToken: string | null = null; let realtimeToken: string | null = null;
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null; let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
let realtimeCoordinatorScope: string | null = null; let realtimeCoordinatorScope: string | null = null;
@@ -136,10 +137,9 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
if (!(await session.exchangeGatewayToken())) return null; if (!(await session.exchangeGatewayToken())) return null;
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null; return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
}; };
const buildRealtimeUrl = (token: string): string => { const buildRealtimeUrl = (): string => {
const base = gameFrontendRuntimeConfig.gameSseUrl; const base = gameFrontendRuntimeConfig.gameSseUrl;
const url = new URL(base, window.location.origin); const url = new URL(base, window.location.origin);
url.searchParams.set('token', token);
url.searchParams.set('scope', 'tournament'); url.searchParams.set('scope', 'tournament');
return url.toString(); return url.toString();
}; };
@@ -181,7 +181,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
if (realtimeSource && realtimeToken === token) return; if (realtimeSource && realtimeToken === token) return;
closeRealtimeSource(); closeRealtimeSource();
realtimeToken = token; realtimeToken = token;
const source = new EventSource(buildRealtimeUrl(token)); const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
realtimeSource = source; realtimeSource = source;
source.addEventListener('open', () => { source.addEventListener('open', () => {
realtimeStatus.value = 'connected'; 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; needsRecovery = true;
realtimeStatus.value = 'idle'; realtimeStatus.value = 'idle';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' }); realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
@@ -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',
});
},
});
};
@@ -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<string> =>
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<void> =>
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<void>((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<number | undefined>((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'));
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify'; import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -89,9 +90,10 @@ export const createGatewayApiServer = async () => {
config.defaultNavigationConfigFile config.defaultNavigationConfigFile
); );
const app = fastify({ const app = fastify({
logger: true, logger: safeHttpLoggerOptions,
routerOptions: gatewayFastifyRouterOptions, routerOptions: gatewayFastifyRouterOptions,
}); });
app.setErrorHandler(safeHttpErrorHandler);
const webPush = new WebPushCoordinator( const webPush = new WebPushCoordinator(
postgres.prisma as GatewayPrismaClient, postgres.prisma as GatewayPrismaClient,
{ {
+2 -1
View File
@@ -1,8 +1,9 @@
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { initTRPC } from '@trpc/server'; import { initTRPC } from '@trpc/server';
import type { GatewayApiContext } from './context.js'; import type { GatewayApiContext } from './context.js';
const t = initTRPC.context<GatewayApiContext>().create(); const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
export const router = t.router; export const router = t.router;
export const procedure = t.procedure; export const procedure = t.procedure;
@@ -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();
}
});
});
+26
View File
@@ -540,3 +540,29 @@ schema head·component 검사는 그대로 수행합니다.
Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부 Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부
Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다. 운영 배포에서는 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를 다시 추가하지 않는다.
+31
View File
@@ -0,0 +1,31 @@
/** Preserve public validation/permission messages while hiding internal causes and stacks. */
export const safeTrpcErrorFormatter = <T extends { message: string; data: { code: string; stack?: string } }>({
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'),
});
};
+75
View File
@@ -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<string, unknown>) : {};
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
)
);
}
},
},
};
+2
View File
@@ -31,6 +31,8 @@ export * from './gateway/profileStatus.js';
export * from './gateway/runtimeDiagnostics.js'; export * from './gateway/runtimeDiagnostics.js';
export * from './game/accessPenalty.js'; export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js'; export * from './http/trpcTransport.js';
export * from './http/safeLogger.js';
export * from './http/errorPrivacy.js';
export * from './webPush/types.js'; export * from './webPush/types.js';
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
+17
View File
@@ -381,6 +381,9 @@ importers:
es-toolkit: es-toolkit:
specifier: ^1.43.0 specifier: ^1.43.0
version: 1.51.0 version: 1.51.0
eventsource:
specifier: 5.1.2
version: 5.1.2
mitt: mitt:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
@@ -3750,6 +3753,14 @@ packages:
eventemitter2@6.4.9: eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} 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: expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -7879,6 +7890,12 @@ snapshots:
eventemitter2@6.4.9: {} eventemitter2@6.4.9: {}
eventsource-parser@4.1.1: {}
eventsource@5.1.2:
dependencies:
eventsource-parser: 4.1.1
expect-type@1.4.0: {} expect-type@1.4.0: {}
exsolve@1.0.8: {} exsolve@1.0.8: {}