fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다

This commit is contained in:
2026-10-10 01:56:31 +09:00
parent 5771701007
commit 4e668b9fb6
31 changed files with 889 additions and 238 deletions
+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();
}
};
};
+1
View File
@@ -33,6 +33,7 @@ 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 });
});
});