SSE 헤더 인증과 연결 수명 검증 및 HTTP 오류 정보 보호
This commit is contained in:
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
+74
-76
@@ -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,88 +375,62 @@ 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 () => {
|
const identities = [viewerIdentity];
|
||||||
if (closed) return;
|
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
||||||
const identities = [viewerIdentity];
|
const nextIdentity = await loadViewerIdentity();
|
||||||
if (shouldReloadRealtimeViewerIdentity(event, viewerIdentity)) {
|
identities.push(nextIdentity);
|
||||||
const nextIdentity = await loadViewerIdentity();
|
viewerIdentity = nextIdentity;
|
||||||
identities.push(nextIdentity);
|
}
|
||||||
viewerIdentity = nextIdentity;
|
let refreshGrant: string | undefined;
|
||||||
}
|
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
||||||
let refreshGrant: string | undefined;
|
refreshGrant ??= createRealtimeAccessGrant(currentAuth, config.profileName, config.gameTokenSecret);
|
||||||
const publicEvent = toPublicRealtimeEvent(event, identities, () => {
|
return refreshGrant;
|
||||||
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.
|
|
||||||
});
|
});
|
||||||
|
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;
|
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;
|
event: 'ping',
|
||||||
sendFrame(
|
data: JSON.stringify({ turnEngineRunning: await turnEngineStatus.get() }),
|
||||||
formatSseFrame({
|
});
|
||||||
event: 'ping',
|
} finally {
|
||||||
data: JSON.stringify({ turnEngineRunning }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
heartbeatPending = false;
|
heartbeatPending = false;
|
||||||
});
|
}
|
||||||
}, 15000);
|
});
|
||||||
|
}, 15_000);
|
||||||
const close = () => {
|
connection.onClose(() => {
|
||||||
closed = true;
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, {
|
type,
|
||||||
data: JSON.stringify({
|
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||||
type,
|
? { refreshGrant: 'fixture-realtime-grant' }
|
||||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
: {}),
|
||||||
? { refreshGrant: 'fixture-realtime-grant' }
|
...((payload as object) ?? {}),
|
||||||
: {}),
|
})}\n\n`
|
||||||
...((payload as object) ?? {}),
|
)
|
||||||
}),
|
);
|
||||||
})
|
},
|
||||||
);
|
};
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
});
|
__hasMainRealtime: () => latest !== null,
|
||||||
Object.defineProperty(window, '__hasMainRealtime', {
|
__mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
|
||||||
configurable: true,
|
|
||||||
value: () => TestEventSource.latest !== null,
|
|
||||||
});
|
|
||||||
Object.defineProperty(window, '__mainRealtimeStats', {
|
|
||||||
configurable: true,
|
|
||||||
value: () => ({
|
|
||||||
active: TestEventSource.latest !== null,
|
|
||||||
created: TestEventSource.created,
|
|
||||||
closed: TestEventSource.closed,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
close() {
|
closed: false,
|
||||||
this.closed = true;
|
authenticated: new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false,
|
||||||
}
|
emit: (type: string, payload: unknown) => {
|
||||||
|
if (!source.closed)
|
||||||
emit(type: string, payload: unknown) {
|
controller.enqueue(
|
||||||
if (this.closed) return;
|
new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
|
||||||
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
|
);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
|
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, {
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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를 다시 추가하지 않는다.
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Generated
+17
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user