운영 보안을 보존하여 빌드 개선 배포본 통합

This commit is contained in:
2026-10-23 01:33:42 +09:00
97 changed files with 5522 additions and 740 deletions
+8
View File
@@ -14,6 +14,10 @@
"types": "./dist/auth/gameToken.d.ts",
"default": "./dist/auth/gameToken.js"
},
"./auth/sessionRevocation": {
"types": "./dist/auth/sessionRevocation.d.ts",
"default": "./dist/auth/sessionRevocation.js"
},
"./auth/sanctions": {
"types": "./dist/auth/sanctions.d.ts",
"default": "./dist/auth/sanctions.js"
@@ -57,6 +61,10 @@
"./util/JosaUtil": {
"types": "./dist/util/JosaUtil.d.ts",
"default": "./dist/util/JosaUtil.js"
},
"./images/imageUpload": {
"types": "./dist/images/imageUpload.d.ts",
"default": "./dist/images/imageUpload.js"
}
},
"scripts": {
@@ -0,0 +1,55 @@
// Gateway/game API는 같은 Redis namespace의 회수 계약을 공유한다.
// 최초 전환 cutoff와 사용자 watermark는 token TTL 설정 변화에도 유효해야 하므로
// TTL로 지우지 않는다. 사용자당 한 값이며 삭제는 모든 기존 token 만료 확인 뒤 운영 작업이다.
export const buildSessionRevocationBaselineKey = (channel: string): string => `${channel}:revocation:v1:baseline`;
export const buildUserSessionRevocationKey = (channel: string, userId: string): string =>
`${channel}:revocation:v1:user:${userId}`;
export const parseSessionRevocationWatermark = (raw: string | null): Date | null => {
if (raw === null) return null;
if (!/^\d{1,16}$/.test(raw)) throw new Error('Invalid session revocation state.');
const value = Number(raw);
if (!Number.isSafeInteger(value) || value > 8_640_000_000_000_000)
throw new Error('Invalid session revocation state.');
return new Date(value);
};
/** A caller timeout does not prove a command already sent to Redis has settled. */
export class SessionRevocationCommandBudget {
private pending = 0;
constructor(private readonly maxPending = 64) {}
async run<T>(start: () => Promise<T>): Promise<T> {
if (this.pending >= this.maxPending) throw new Error('Session revocation storage is unavailable.');
this.pending += 1;
let command: Promise<T>;
try {
command = start();
} catch {
this.pending -= 1;
throw new Error('Session revocation storage is unavailable.');
}
void command.then(
() => {
this.pending -= 1;
},
() => {
this.pending -= 1;
}
);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
command,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error('Session revocation storage is unavailable.')), 2000);
timeout.unref();
}),
]);
} catch {
throw new Error('Session revocation storage is unavailable.');
} finally {
clearTimeout(timeout);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
const MAX_ORIGINS = 32;
const MAX_ORIGIN_LENGTH = 2048;
const parsePublicUrl = (value: string): URL => {
if (value.length > MAX_ORIGIN_LENGTH) throw new Error('API origin configuration is too long.');
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('API origins require a public HTTP(S) URL without credentials.');
}
return url;
};
/** Public application URLs supply the default origins; extra origins must be explicit. */
export const resolveApiAllowedOrigins = (
extraOrigins: string | undefined,
publicUrls: readonly (string | undefined)[]
): string[] => {
const origins = new Set<string>();
for (const value of publicUrls) {
if (value?.trim()) origins.add(parsePublicUrl(value.trim()).origin);
}
if (extraOrigins?.trim()) {
const entries = extraOrigins.split(',');
if (entries.length > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
for (const value of entries) {
const url = parsePublicUrl(value.trim());
if (url.pathname !== '/' || url.search || url.hash) {
throw new Error('API_ALLOWED_ORIGINS entries must be origins, without paths or queries.');
}
origins.add(url.origin);
}
}
if (origins.size > MAX_ORIGINS) throw new Error('Too many API allowed origins.');
return [...origins];
};
class ApiOriginForbiddenError extends Error {
readonly statusCode = 403;
constructor() {
super('Request origin is not allowed.');
}
}
/** Reject browser requests before parsing bodies or dispatching mutations, not just CORS responses. */
export const createApiOriginGuard = (allowedOrigins: readonly string[]) => {
const allowed = new Set(allowedOrigins);
return async (request: { headers: { origin?: string | string[] } }): Promise<void> => {
const origin = request.headers.origin;
// Internal machine clients have no Origin and still require their ordinary auth/HMAC.
if (origin === undefined) return;
if (typeof origin !== 'string' || origin.length > MAX_ORIGIN_LENGTH || !allowed.has(origin)) {
throw new ApiOriginForbiddenError();
}
};
};
+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
)
);
}
},
},
};
+143
View File
@@ -0,0 +1,143 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
export class ImageWorkBusyError extends Error {
constructor() {
super('Image service is busy.');
}
}
/** Admission lasts until actual work settles, including work that ignores abort. No queue. */
export class ImageWorkBudget {
private pending = 0;
constructor(private readonly limit = 4) {}
async run<T>(work: () => Promise<T>): Promise<T> {
if (this.pending >= this.limit) throw new ImageWorkBusyError();
this.pending++;
try {
return await work();
} finally {
this.pending--;
}
}
}
export class ImageUploadError extends Error {}
const uploadBudget = new ImageWorkBudget();
export const MAX_IMAGE_RESPONSE_BYTES = 16 * 1024;
export const IMAGE_UPLOAD_TIMEOUT_MS = 10_000;
export const isRasterImage = (body: Buffer): boolean =>
body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
(body[0] === 255 && body[1] === 216 && body[2] === 255) ||
['GIF87a', 'GIF89a'].includes(body.toString('ascii', 0, 6)) ||
(body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') ||
body.toString('ascii', 0, 2) === 'BM' ||
['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(body.subarray(0, 4).toString('hex')) ||
(body.length >= 12 && body.toString('ascii', 4, 8) === 'ftyp');
export const performSignedImageUpload = async (
input: {
kind: 'content' | 'user-icons';
baseUrl: string;
filename: string;
contentType: string;
body: Buffer;
secret: string;
},
fetchImpl: typeof fetch = fetch,
now: () => number = Date.now,
options: { timeoutMs?: number; budget?: ImageWorkBudget } = {}
): Promise<void> => {
const base = new URL(input.baseUrl);
if (
!['http:', 'https:'].includes(base.protocol) ||
base.username ||
base.password ||
base.search ||
base.hash ||
!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename) ||
input.body.length > 16 * 1024 * 1024
) {
throw new ImageUploadError('Invalid image upload.');
}
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const work = (options.budget ?? uploadBudget).run(async () => {
const pathname = `/v1/uploads/${input.kind}/core2026/${input.filename}`;
const expires = String(Math.floor(now() / 1000) + 60);
const requestId = randomUUID();
const digest = createHash('sha256').update(input.body).digest('hex');
const signature = createHmac('sha256', input.secret)
.update(`${expires}.${requestId}.${pathname}.${input.contentType}.${digest}`)
.digest('hex');
const response = await fetchImpl(`${input.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
redirect: 'error',
signal: controller.signal,
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature,
},
body: new Uint8Array(input.body),
});
if (controller.signal.aborted || !response.ok) {
await response.body?.cancel();
throw new ImageUploadError(
controller.signal.aborted
? 'Image upload timed out.'
: `Image repository upload failed with HTTP ${response.status}.`
);
}
const declared = Number(response.headers.get('content-length') ?? 0);
if (declared > MAX_IMAGE_RESPONSE_BYTES) {
await response.body?.cancel();
throw new ImageUploadError('Image response exceeds its size limit.');
}
const reader = response.body?.getReader();
if (!reader) throw new ImageUploadError('Image repository returned an invalid response.');
const chunks: Uint8Array[] = [];
let total = 0;
try {
for (;;) {
const chunk = await reader.read();
if (controller.signal.aborted) throw new ImageUploadError('Image upload timed out.');
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > MAX_IMAGE_RESPONSE_BYTES)
throw new ImageUploadError('Image response exceeds its size limit.');
chunks.push(chunk.value);
}
} catch (error) {
await reader.cancel();
throw error;
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
const expected =
input.kind === 'content' ? `uploads/core2026/${input.filename}` : `icons/users/core2026/${input.filename}`;
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== expected) {
throw new ImageUploadError('Image repository returned an unexpected upload path.');
}
});
try {
await Promise.race([
work,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new ImageUploadError('Image upload timed out.'));
}, options.timeoutMs ?? IMAGE_UPLOAD_TIMEOUT_MS);
}),
]);
} catch (error) {
if (error instanceof ImageWorkBusyError || error instanceof ImageUploadError) throw error;
// Request errors can contain credential-bearing URLs and signatures.
throw new ImageUploadError('Image repository upload failed.');
} finally {
clearTimeout(timer);
}
};
+3
View File
@@ -31,6 +31,9 @@ export * from './gateway/profileStatus.js';
export * from './gateway/runtimeDiagnostics.js';
export * from './game/accessPenalty.js';
export * from './http/trpcTransport.js';
export * from './http/safeLogger.js';
export * from './http/errorPrivacy.js';
export * from './http/apiOrigin.js';
export * from './webPush/types.js';
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
+37
View File
@@ -307,6 +307,21 @@ export type TurnDaemonCommand =
| { kind: 'nationPriority'; priority: string[] }
| { kind: 'generalPriority'; priority: string[] };
}
| {
type: 'tournamentAdjustGeneral';
requestId?: string;
reason: 'tournamentJoin' | 'tournamentJoinRollback' | 'tournamentBet' | 'tournamentBetRollback';
userId: string;
generalId: number;
goldDelta: number;
betGoldDelta?: number;
minGoldAfter: number;
}
| {
type: 'tournamentSeedNpcBets';
requestId?: string;
bets: Array<{ generalId: number; amount: number }>;
}
| {
type: 'adjustGeneralResources';
requestId?: string;
@@ -701,6 +716,28 @@ export type TurnDaemonCommandResult =
nationId?: number;
currentUpdatedAt?: string | null;
}
| {
type: 'tournamentAdjustGeneral';
ok: true;
generalId: number;
}
| {
type: 'tournamentAdjustGeneral';
ok: false;
code: 'FORBIDDEN' | 'BAD_REQUEST';
reason: string;
}
| {
type: 'tournamentSeedNpcBets';
ok: true;
processedGeneralIds: number[];
skippedGeneralIds: number[];
}
| {
type: 'tournamentSeedNpcBets';
ok: false;
reason: string;
}
| {
type: 'adjustGeneralResources';
ok: true;
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { createApiOriginGuard, resolveApiAllowedOrigins } from '../src/http/apiOrigin.js';
describe('public API origin policy', () => {
it('derives exact origins from application URLs and explicit extra origins', async () => {
const origins = resolveApiAllowedOrigins('http://localhost:5173,https://sam.hided.net', [
'https://sam.hided.net/gateway/',
'https://sam.hided.net/che/',
]);
expect(origins).toEqual(['https://sam.hided.net', 'http://localhost:5173']);
const guard = createApiOriginGuard(origins);
await expect(guard({ headers: {} })).resolves.toBeUndefined();
for (const origin of origins) await expect(guard({ headers: { origin } })).resolves.toBeUndefined();
for (const origin of [
'https://untrusted.example',
'https://sam.hided.net.untrusted.example',
'http://sam.hided.net',
'https://sam.hided.net:8443',
'null',
'',
'https://sam.hided.net,https://untrusted.example',
'x'.repeat(2049),
['https://sam.hided.net', 'https://untrusted.example'],
]) {
await expect(guard({ headers: { origin } })).rejects.toMatchObject({ statusCode: 403 });
}
});
it.each([
'*',
'null',
'https://user:password@example.com',
'file:///tmp/example',
'https://x.test/path',
'https://x.test?query',
'https://x.test#fragment',
'https://x.test,',
])('rejects unsafe configuration %s', (value) => {
expect(() => resolveApiAllowedOrigins(value, [])).toThrow();
});
it('bounds configuration and fails closed when no public origin is configured', async () => {
expect(() =>
resolveApiAllowedOrigins(Array.from({ length: 33 }, (_, i) => `https://${i}.test`).join(','), [])
).toThrow();
expect(() => resolveApiAllowedOrigins('https://' + 'a'.repeat(2048) + '.test', [])).toThrow();
await expect(
createApiOriginGuard([])({ headers: { origin: 'https://untrusted.example' } })
).rejects.toMatchObject({ statusCode: 403 });
});
});
+99
View File
@@ -0,0 +1,99 @@
import { createServer, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { ImageWorkBudget, performSignedImageUpload } from '../src/images/imageUpload.js';
const input = {
kind: 'content' as const,
baseUrl: 'https://image.example',
filename: `${'a'.repeat(32)}.png`,
contentType: 'image/png',
body: Buffer.from('test'),
secret: 'synthetic-image-secret',
};
const payload = () => new Response(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${(server.address() as { port: number }).port}`;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
describe('bounded signed image transport', () => {
it('never forwards signed PUT bodies across a real redirect', async () => {
let forwarded = 0;
const target = createServer((_, res) => {
forwarded++;
res.end(JSON.stringify({ path: `uploads/core2026/${input.filename}` }));
});
const targetUrl = await listen(target);
const origin = createServer((_, res) => {
res.writeHead(307, { location: targetUrl });
res.end();
});
const baseUrl = await listen(origin);
try {
await expect(performSignedImageUpload({ ...input, baseUrl })).rejects.toThrow();
expect(forwarded).toBe(0);
} finally {
await close(origin);
await close(target);
}
});
it('bounds streamed bytes independently of Content-Length', async () => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10_000));
controller.enqueue(new Uint8Array(10_000));
},
});
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow('size limit');
});
it('rejects wrong and malformed JSON paths', async () => {
for (const body of ['{', JSON.stringify({ path: 'elsewhere' })]) {
await expect(performSignedImageUpload(input, async () => new Response(body))).rejects.toThrow();
}
});
it('keeps timed-out non-cooperative work admitted until it actually settles', async () => {
const budget = new ImageWorkBudget(1);
let resolve!: (response: Response) => void;
const fetchImpl: typeof fetch = () =>
new Promise<Response>((done) => {
resolve = done;
});
await expect(performSignedImageUpload(input, fetchImpl, Date.now, { timeoutMs: 25, budget })).rejects.toThrow(
'timed out'
);
await expect(performSignedImageUpload(input, async () => payload(), Date.now, { budget })).rejects.toThrow(
'busy'
);
resolve(payload());
await new Promise<void>((done) => setImmediate(done));
await expect(
performSignedImageUpload(input, async () => payload(), Date.now, { budget })
).resolves.toBeUndefined();
});
it.each(['headers', 'body'])(
'aborts a real stalled %s response within the whole request deadline',
async (phase) => {
const server = createServer((_, res) => {
if (phase === 'body') {
res.writeHead(201);
res.write('{');
}
});
const baseUrl = await listen(server);
try {
await expect(
performSignedImageUpload({ ...input, baseUrl }, fetch, Date.now, { timeoutMs: 100 })
).rejects.toThrow('timed out');
} finally {
await close(server);
}
}
);
});
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { SessionRevocationCommandBudget, parseSessionRevocationWatermark } from '../src/auth/sessionRevocation.js';
describe('session revocation command ownership', () => {
it('holds capacity after caller timeout until the actual command settles, then recovers', async () => {
vi.useFakeTimers();
const budget = new SessionRevocationCommandBudget(1);
let finish!: () => void;
const actual = new Promise<void>((resolve) => {
finish = resolve;
});
try {
const timed = expect(budget.run(() => actual)).rejects.toThrow('unavailable');
await vi.advanceTimersByTimeAsync(2000);
await timed;
const extra = vi.fn(async () => 'extra');
await expect(budget.run(extra)).rejects.toThrow('unavailable');
expect(extra).not.toHaveBeenCalled();
finish();
await actual;
await expect(budget.run(extra)).resolves.toBe('extra');
} finally {
finish();
vi.useRealTimers();
}
});
it('releases capacity after synchronous startup or asynchronous backend failure', async () => {
const budget = new SessionRevocationCommandBudget(1);
await expect(
budget.run(() => {
throw new Error('synthetic');
})
).rejects.toThrow('unavailable');
await expect(
budget.run(async () => {
throw new Error('synthetic');
})
).rejects.toThrow('unavailable');
await expect(budget.run(async () => 'ok')).resolves.toBe('ok');
});
it('validates numeric persisted timestamps while distinguishing absent state', () => {
expect(parseSessionRevocationWatermark(null)).toBeNull();
expect(parseSessionRevocationWatermark('0')?.getTime()).toBe(0);
expect(() => parseSessionRevocationWatermark('NaN')).toThrow();
expect(() => parseSessionRevocationWatermark('8640000000000001')).toThrow();
});
});
+2
View File
@@ -4,8 +4,10 @@ import { emitDeclarations } from '../../tools/build-scripts/emit-declarations.mj
export default defineConfig({
entry: {
index: 'src/index.ts',
'images/imageUpload': 'src/images/imageUpload.ts',
'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sessionRevocation': 'src/auth/sessionRevocation.ts',
'auth/sanctions': 'src/auth/sanctions.ts',
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
'http/trpcTransport': 'src/http/trpcTransport.ts',