fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다
This commit is contained in:
1 parent
5771701007
commit
4e668b9fb6
31 files changed
+889
-238
No files matched your search
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user