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

This commit is contained in:
Hide_D committed 2026-10-10 01:56:31 +09:00
1 parent 5771701007
commit 4e668b9fb6
31 files changed
+889 -238

No files matched your search

+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;