fix: 공개 API와 토너먼트의 사용자·장수 권한 경계를 강화한다
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user