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

This commit is contained in:
2026-10-23 01:33:42 +09:00
97 changed files with 5522 additions and 740 deletions
@@ -1,4 +1,4 @@
import { createHash, createHmac, randomUUID } from 'node:crypto';
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
export interface UserIconUploadResult {
picture: string;
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
}
const signature = (
secret: string,
expires: string,
requestId: string,
pathname: string,
contentType: string,
body: Buffer
): string => {
const digest = createHash('sha256').update(body).digest('hex');
return createHmac('sha256', secret)
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
.digest('hex');
};
export class RemoteUserIconStore implements UserIconUploadStore {
constructor(
private readonly baseUrl: string,
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
throw new Error('Invalid user icon filename.');
}
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
const expires = String(Math.floor(this.now() / 1000) + 60);
const requestId = randomUUID();
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
method: 'PUT',
headers: {
'content-type': input.contentType,
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': requestId,
'x-image-signature': signature(
this.secret,
expires,
requestId,
pathname,
input.contentType,
input.body
),
await performSignedImageUpload(
{
...input,
kind: 'user-icons',
baseUrl: this.baseUrl,
secret: this.secret,
},
body: new Uint8Array(input.body),
});
if (!response.ok) {
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
}
this.fetchImpl,
this.now
);
const picture = `users/core2026/${input.filename}`;
const payload: unknown = await response.json();
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
throw new Error('Image repository returned an unexpected upload path.');
}
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
}
}
+92 -44
View File
@@ -1,3 +1,9 @@
import {
ImageWorkBudget,
ImageWorkBusyError,
ImageUploadError,
isRasterImage,
} from '@sammo-ts/common/images/imageUpload';
import { randomBytes } from 'node:crypto';
import { TRPCError } from '@trpc/server';
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
const zSessionToken = z.string().min(1);
const iconImageBudget = new ImageWorkBudget();
const MAX_IMAGE_PIXELS = 128 * 128 * 256;
const MAX_ICON_BYTES = 50 * 1024;
const MAX_ACTIVE_ICONS = 5;
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
@@ -265,52 +274,91 @@ export const accountRouter = router({
)
.mutation(async ({ ctx, input }) => {
const user = await requireSessionUser(ctx, input.sessionToken);
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
const metadata = await sharp(buffer, { animated: true }).metadata();
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
try {
return await iconImageBudget.run(async () => {
const now = new Date();
const profiles = await listIconSyncProfiles(ctx, user.id);
const buffer = decodeImage(input.imageData);
if (!isRasterImage(buffer))
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
.timeout({ seconds: 10 })
.metadata()
.catch(() => {
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
});
if ((metadata.pages ?? 1) > 256)
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
});
}
if (
!metadata.width ||
metadata.width < 64 ||
metadata.width > 128 ||
metadata.height !== metadata.width
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
});
}
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
if (!ctx.userIconUpload) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '이미지 저장소가 설정되지 않았습니다.',
});
}
const uploaded = await ctx.userIconUpload.upload({
filename,
contentType: ICON_CONTENT_TYPES[extension]!,
body: buffer,
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
const stored = await ctx.users.addIconForWindow(
user.id,
uploaded.picture,
0,
now,
MAX_ACTIVE_ICONS
);
if (!stored.ok) {
if (stored.reason === 'LIMIT') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
});
}
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
});
} catch (error) {
if (error instanceof ImageWorkBusyError)
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
});
if (error instanceof ImageUploadError)
throw new TRPCError({
code: 'SERVICE_UNAVAILABLE',
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
});
throw error;
}
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
return {
ok: true,
iconUrl: uploaded.publicUrl,
revision: stored.revision,
icon: buildLibraryIcon(ctx, stored.icon),
profiles,
flushPublished,
};
}),
setPreferredIcon: procedure
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
+196
View File
@@ -0,0 +1,196 @@
import { createHmac } from 'node:crypto';
export interface AuthCounter {
key: string;
limit: number;
windowMs: number;
}
export interface AuthCounterStore {
readonly available?: boolean;
/** Atomically check every counter, then charge them only when accepted. */
consume(counters: AuthCounter[]): Promise<number>;
}
export class AuthBudgetError extends Error {
constructor(
readonly reason: 'limited' | 'unavailable',
readonly retryAfterSeconds = 1
) {
super(
reason === 'limited'
? 'Authentication attempts are temporarily limited.'
: 'Authentication protection is unavailable.'
);
}
}
const CONSUME_SCRIPT = `
local retry = 0
for i, key in ipairs(KEYS) do
local count = tonumber(redis.call('GET', key) or '0')
if count >= tonumber(ARGV[i * 2 - 1]) then
local ttl = redis.call('PTTL', key)
if ttl < 0 then
return -1
end
retry = math.max(retry, math.max(ttl, 1))
end
end
if retry > 0 then return retry end
for i, key in ipairs(KEYS) do
local count = redis.call('INCR', key)
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
end
return 0
`;
export class RedisAuthCounterStore implements AuthCounterStore {
constructor(
private readonly client: {
readonly isReady?: boolean;
withAbortSignal?: (signal: AbortSignal) => {
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
};
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
}
) {}
get available(): boolean {
return this.client.isReady !== false;
}
async consume(counters: AuthCounter[]): Promise<number> {
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
const result = await client.eval(CONSUME_SCRIPT, {
keys: counters.map((counter) => counter.key),
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
});
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
throw new AuthBudgetError('unavailable');
}
return result;
}
}
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
export class InMemoryAuthCounterStore implements AuthCounterStore {
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
async consume(counters: AuthCounter[]): Promise<number> {
const now = Date.now();
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
throw new AuthBudgetError('unavailable');
}
let retryMs = 0;
for (const counter of counters) {
const entry = this.entries.get(counter.key);
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
}
if (retryMs > 0) return retryMs;
for (const counter of counters) {
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
entry.count += 1;
this.entries.set(counter.key, entry);
}
return 0;
}
}
const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal',
'auth.kakaoExchange',
'auth.kakaoResolveAccount',
'auth.kakaoSetPassword',
'auth.register',
'auth.registerLocal',
'auth.login',
'auth.kakaoOtp',
'account.changePassword',
'account.scheduleDeletion',
]);
const LIGHTWEIGHT_ACTIONS = new Set([
'auth.passwordKey',
'auth.checkRegistrationField',
'auth.kakaoStart',
'auth.issueGameSession',
]);
export const isBudgetedAuthAction = (action: string): boolean =>
EXPENSIVE_ACTIONS.has(action) || LIGHTWEIGHT_ACTIONS.has(action);
export class AuthAttemptBudget {
private active = 0;
private pendingChecks = 0;
constructor(
private readonly store: AuthCounterStore,
private readonly prefix: string,
private readonly secret: string
) {}
private key(kind: string, value: string): string {
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
}
async run<T>(
action: string,
client: string,
subject: string | undefined | (() => Promise<string | undefined>),
work: () => Promise<T>
): Promise<T> {
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
const expensive = EXPENSIVE_ACTIONS.has(action);
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
if (expensive) this.active += 1;
try {
const consume = async (): Promise<number> => {
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
const counters: AuthCounter[] = [
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
];
if (resolvedSubject)
counters.push({
key: this.key('subject', resolvedSubject),
limit: action === 'auth.issueGameSession' ? 32 : 10,
windowMs: 60_000,
});
return this.store.consume(counters);
};
this.pendingChecks += 1;
const checking = consume();
// A caller timeout does not prove the underlying Redis/actor read settled.
void checking.then(
() => {
this.pendingChecks -= 1;
},
() => {
this.pendingChecks -= 1;
}
);
let timeout: ReturnType<typeof setTimeout> | undefined;
let retryMs: number;
try {
retryMs = await Promise.race([
checking,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
timeout.unref();
}),
]);
} catch {
throw new AuthBudgetError('unavailable');
} finally {
clearTimeout(timeout);
}
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
return await work();
} finally {
if (expensive) this.active -= 1;
}
}
}
+52 -3
View File
@@ -1,3 +1,28 @@
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
SessionRevocationCommandBudget,
} from '@sammo-ts/common/auth/sessionRevocation';
interface RedisClientLike {
readonly isReady?: boolean;
set(key: string, value: string, options: { NX: true }): Promise<string | null>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
withAbortSignal?(signal: AbortSignal): RedisClientLike;
}
// 지속 watermark를 먼저 저장한 뒤 알림을 보낸다. 구독자가 0이어도 회수는 유효하다.
const PUBLISH_FLUSH_SCRIPT = `
redis.call('SET', KEYS[1], ARGV[1], 'NX')
local raw = redis.call('GET', KEYS[2])
local prior = tonumber(raw)
if raw and not prior then return redis.error_reply('Invalid session revocation state') end
local cutoff = math.max(prior or 0, tonumber(ARGV[1]))
redis.call('SET', KEYS[2], string.format('%.0f', cutoff))
redis.call('PUBLISH', ARGV[2], ARGV[3])
return 1
`;
export interface GatewayUserFlushEvent {
userId: string;
flushedAt: string;
@@ -17,13 +42,28 @@ export interface GatewayFlushPublisher {
export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
private readonly channel: string;
private readonly client: { publish: (channel: string, message: string) => Promise<number> };
private readonly client: RedisClientLike;
private readonly commands = new SessionRevocationCommandBudget(32);
constructor(client: { publish: (channel: string, message: string) => Promise<number> }, channel: string) {
constructor(client: RedisClientLike, channel: string) {
this.client = client;
this.channel = channel;
}
private commandClient(): RedisClientLike {
if (this.client.isReady === false) throw new Error('Session revocation storage is unavailable.');
return this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
}
async initialize(): Promise<void> {
// 새 계약 최초 활성화에서만 기존 게임 credential을 회수한다. 재시작은 cutoff를 보존한다.
await this.commands.run(() =>
this.commandClient().set(buildSessionRevocationBaselineKey(this.channel), String(Date.now()), {
NX: true,
})
);
}
async publishUserFlush(
userId: string,
reason?: string,
@@ -37,6 +77,15 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher {
...(metadata?.displayName ? { displayName: metadata.displayName } : {}),
...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}),
};
await this.client.publish(this.channel, JSON.stringify(payload));
const result = await this.commands.run(() =>
this.commandClient().eval(PUBLISH_FLUSH_SCRIPT, {
keys: [
buildSessionRevocationBaselineKey(this.channel),
buildUserSessionRevocationKey(this.channel, userId),
],
arguments: [String(Date.parse(payload.flushedAt)), this.channel, JSON.stringify(payload)],
})
);
if (result !== 1) throw new Error('Session revocation persistence failed.');
}
}
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
import type {
GameSessionInfo,
@@ -60,7 +61,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
if (Date.now() >= stored.expiresAt) {
this.sessions.delete(sessionToken);
this.sessionGames.delete(sessionToken);
return null;
@@ -86,9 +87,19 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
const session = await this.getSession(sessionToken);
if (!session) {
const parent = this.sessions.get(sessionToken);
if (!session || !parent || parent.info !== session || parent.expiresAt <= Date.now()) {
return null;
}
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
for (const key of set) {
const stored = this.gameSessions.get(key);
if (!stored || stored.expiresAt <= Date.now()) {
this.gameSessions.delete(key);
set.delete(key);
}
}
if (set.size >= MAX_GAME_SESSIONS_PER_SESSION) throw new GameSessionLimitError();
const gameToken = randomUUID();
const info: GameSessionInfo = {
profile,
@@ -109,7 +120,6 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
info,
expiresAt: Date.now() + this.gameSessionTtlMs,
});
const set = this.sessionGames.get(sessionToken) ?? new Set<string>();
set.add(key);
this.sessionGames.set(sessionToken, set);
return info;
@@ -121,7 +131,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService {
if (!stored) {
return null;
}
if (Date.now() > stored.expiresAt) {
if (Date.now() >= stored.expiresAt) {
this.gameSessions.delete(key);
return null;
}
+56 -15
View File
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
redirectUri: string;
oauthHost?: string;
apiHost?: string;
requestTimeoutMs?: number;
}
export interface KakaoOAuthToken {
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
private readonly redirectUri: string;
private readonly oauthHost: string;
private readonly apiHost: string;
private readonly requestTimeoutMs: number;
constructor(config: KakaoOAuthConfig) {
this.restKey = config.restKey;
this.redirectUri = config.redirectUri;
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
}
}
private async requestJson(
url: URL,
init: RequestInit
): Promise<{ response: Response; payload: Record<string, unknown> }> {
try {
const response = await fetch(url, {
...init,
redirect: 'error',
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
if (!response.body) throw new Error('Missing response body.');
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024) {
void reader.cancel().catch(() => undefined);
throw new Error('Response is too large.');
}
chunks.push(chunk.value);
}
} finally {
reader.releaseLock();
}
const payload: unknown = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
);
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
throw new Error('Invalid response shape.');
return { response, payload: payload as Record<string, unknown> };
} catch {
// Provider/network payloads can include access tokens or account details.
throw new Error('Kakao HTTP request failed.');
}
}
buildAuthUrl(state: string, scopes: string[]): string {
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
}
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
code,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth token error.');
}
return parseToken(payload);
}
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
refresh_token: refreshToken,
}),
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
throw new Error('Kakao OAuth refresh error.');
}
return parseToken(payload);
}
async signup(accessToken: string): Promise<KakaoSignupResult> {
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
return {
alreadyRegistered: true,
};
}
if (!response.ok) {
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
throw new Error('Kakao signup error.');
}
return {
id: payload.id ? String(payload.id) : undefined,
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
}
async getMe(accessToken: string): Promise<KakaoUserInfo> {
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const payload = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
throw new Error('Kakao me error.');
}
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
return {
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
}
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
}),
}),
});
const payload = (await response.json()) as Record<string, unknown>;
const code = Number(payload.code ?? 0);
if (!response.ok || code < 0) {
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
throw new Error('Kakao talk message error.');
}
}
}
+53 -31
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
import { createGatewayRedisKeyBuilder } from './redisKeys.js';
import { GameSessionLimitError, MAX_GAME_SESSIONS_PER_SESSION } from './sessionService.js';
import type {
GameSessionInfo,
GatewaySessionConfig,
@@ -15,22 +16,38 @@ interface RedisGatewaySessionOptions extends GatewaySessionConfig {
keyPrefix: string;
}
interface RedisPipeline {
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
sAdd(key: string, member: string): RedisPipeline;
expire(key: string, seconds: number): RedisPipeline;
del(key: string): RedisPipeline;
exec(): Promise<unknown>;
}
interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
sMembers(key: string): Promise<string[]>;
multi(): RedisPipeline;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
del(key: string): Promise<number>;
}
// 부모 확인·만료 항목 정리·상한·새 항목 저장을 한 원자적 경계에서 수행한다.
// 기존 oversized index는 SMEMBERS로 펼치지 않고 발급을 거절한다.
const CREATE_GAME_SESSION_SCRIPT = `
local parentTtl = redis.call('PTTL', KEYS[1])
if parentTtl <= 0 or redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
local limit = tonumber(ARGV[4])
if redis.call('SCARD', KEYS[2]) > limit then return -1 end
for _, entry in ipairs(redis.call('SMEMBERS', KEYS[2])) do
if redis.call('EXISTS', entry) == 0 then redis.call('SREM', KEYS[2], entry) end
end
if redis.call('SCARD', KEYS[2]) >= limit then return -1 end
redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[3])
redis.call('SADD', KEYS[2], KEYS[3])
redis.call('PEXPIRE', KEYS[2], math.max(parentTtl, tonumber(ARGV[3]) * 1000))
return 1
`;
// 삭제와 index pop도 한 경계다. 중간 실패에서 추적만 잃는 것을 방지한다.
const REVOKE_GAME_SESSION_BATCH_SCRIPT = `
redis.call('DEL', KEYS[1])
local entries = redis.call('SPOP', KEYS[2], 64)
for _, entry in ipairs(entries) do redis.call('DEL', entry) end
return #entries
`;
// Redis 세션 저장소는 게이트웨이와 게임 서버 간 SSO 토큰을 관리한다.
export class RedisGatewaySessionService implements GatewaySessionService {
private readonly client: RedisClientLike;
@@ -75,22 +92,21 @@ export class RedisGatewaySessionService implements GatewaySessionService {
options: SessionRevocationOptions = { revokeGames: true }
): Promise<void> {
const key = this.keys.sessionKey(sessionToken);
if (options.revokeGames ?? true) {
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
const games = await this.client.sMembers(gameSetKey);
if (games.length > 0) {
const pipeline = this.client.multi();
for (const entry of games) {
pipeline.del(entry);
}
pipeline.del(gameSetKey);
pipeline.del(key);
await pipeline.exec();
return;
}
await this.client.del(gameSetKey);
if (!(options.revokeGames ?? true)) {
await this.client.del(key);
return;
}
// 부모 제거와 각 64개 삭제를 원자적으로 처리한다. 이후 발급 Lua는 실패한다.
// 이전 버전에서 커진 index도 응답/transaction 한 번에 전부 펼치지 않는다.
for (;;) {
const count = await this.client.eval(REVOKE_GAME_SESSION_BATCH_SCRIPT, {
keys: [key, this.keys.sessionGameSetKey(sessionToken)],
arguments: [],
});
if (typeof count !== 'number' || count < 0 || count > 64)
throw new Error('Unexpected game session revocation result.');
if (count < 64) break;
}
await this.client.del(key);
}
async createGameSession(sessionToken: string, profile: string): Promise<GameSessionInfo | null> {
@@ -115,12 +131,18 @@ export class RedisGatewaySessionService implements GatewaySessionService {
};
const gameKey = this.keys.gameSessionKey(profile, gameToken);
const gameSetKey = this.keys.sessionGameSetKey(sessionToken);
await this.client
.multi()
.set(gameKey, JSON.stringify(info), { EX: this.gameSessionTtlSeconds })
.sAdd(gameSetKey, gameKey)
.expire(gameSetKey, this.sessionTtlSeconds)
.exec();
const result = await this.client.eval(CREATE_GAME_SESSION_SCRIPT, {
keys: [this.keys.sessionKey(sessionToken), gameSetKey, gameKey],
arguments: [
JSON.stringify(session),
JSON.stringify(info),
String(this.gameSessionTtlSeconds),
String(MAX_GAME_SESSIONS_PER_SESSION),
],
});
if (result === 0) return null;
if (result === -1) throw new GameSessionLimitError();
if (result !== 1) throw new Error('Unexpected game session admission result.');
return info;
}
@@ -1,5 +1,13 @@
import type { UserRecord, UserSanctions } from './userRepository.js';
// 한 Gateway 로그인에서 여러 profile/탭을 사용하되 추적 자료는 유한하게 유지한다.
export const MAX_GAME_SESSIONS_PER_SESSION = 256;
export class GameSessionLimitError extends Error {
constructor() {
super('Game session limit reached.');
}
}
export interface GatewaySessionInfo {
sessionToken: string;
userId: string;
+3 -1
View File
@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { parseBooleanWithFallback, parseNumberWithFallback } from '@sammo-ts/common';
import { parseBooleanWithFallback, parseNumberWithFallback, resolveApiAllowedOrigins } from '@sammo-ts/common';
import { resolveFrontendServeMode, type FrontendServeMode } from './orchestrator/frontendArtifactManager.js';
export interface GatewayApiConfig {
@@ -19,6 +19,7 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
corsAllowedOrigins: string[];
userIconDir: string;
userIconPublicUrl: string;
imageUploadBaseUrl: string;
@@ -129,6 +130,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
corsAllowedOrigins: resolveApiAllowedOrigins(env.API_ALLOWED_ORIGINS, [publicBaseUrl]),
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
+9
View File
@@ -1,3 +1,4 @@
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
import type { GatewaySessionService } from './auth/sessionService.js';
import type { UserRepository } from './auth/userRepository.js';
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
releases: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget: AuthAttemptBudget;
requestIp: string;
requestHeaders: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit: AdminAuditStore;
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
releases?: GatewayReleaseRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
authBudget?: AuthAttemptBudget;
requestIp?: string;
requestHeaders?: Record<string, string | string[] | undefined>;
prisma: GatewayPrismaClient;
adminAudit?: AdminAuditStore;
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
orchestrator: options.orchestrator,
profileStatus: options.profileStatus,
authBudget:
options.authBudget ??
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
requestIp: options.requestIp ?? 'unknown',
requestHeaders: options.requestHeaders ?? {},
prisma: options.prisma,
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
+151 -40
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
export interface BuildCommand {
@@ -33,7 +34,8 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
const RELEASE_BUILD_ENV_NAME =
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
error?: string;
}
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
const record = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
if (!record(value)) return false;
if (Object.keys(value).length !== 1) return false;
if (value.result) {
const result = value.result;
return (
record(result) &&
typeof result.ok === 'boolean' &&
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
typeof result.output === 'string' &&
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
(result.aborted === undefined || typeof result.aborted === 'boolean')
);
}
if (typeof value.error === 'string') return value.error.length <= 2000;
if (!record(value.event)) return false;
const event = value.event;
if (event.type === 'OUTPUT')
return (
typeof event.stream === 'string' &&
['stdout', 'stderr'].includes(event.stream) &&
typeof event.message === 'string' &&
event.message.length <= 2000
);
if (
typeof event.type !== 'string' ||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
!record(event.command)
)
return false;
const command = event.command;
if (
typeof command.command !== 'string' ||
!['pnpm', 'node'].includes(command.command) ||
typeof command.cwd !== 'string' ||
command.cwd.length > 2048 ||
!Array.isArray(command.args) ||
command.args.length > 40 ||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
)
return false;
if (
command.env !== undefined &&
(!record(command.env) ||
Object.keys(command.env).length > 32 ||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
)
return false;
return (
event.type === 'COMMAND_START' ||
event.exitCode === null ||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
);
};
const readRemoteBuildToken = (filename: string | undefined): string => {
try {
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
const token = readFileSync(filename, 'utf8').trim();
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
return token;
} catch {
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
}
};
export interface RemoteBuildOptions {
tokenFile?: string;
timeoutMs?: number;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
private readonly timeoutMs: number;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
constructor(
baseUrl: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly config: RemoteBuildOptions = {}
) {
const parsed = new URL('/v1/builds', baseUrl);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error('Invalid release builder endpoint.');
}
this.endpoint = parsed.toString();
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
throw new Error('Invalid release builder deadline.');
}
}
async run(
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
timeout.unref();
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const token = readRemoteBuildToken(this.config.tokenFile);
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
redirect: 'error',
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
}),
signal: options?.signal,
signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
await response.body?.cancel().catch(() => undefined);
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
}
const decoder = new TextDecoder();
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
}
if (result.result) return result.result;
if (result.error) {
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) {
buffer += decoder.decode();
break;
}
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
buffer += decoder.decode(chunk.value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
if (result.result) return result.result;
if (result.error)
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, 'Release builder closed without a result.'),
};
} catch {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
),
...(aborted ? { aborted: true } : {}),
};
} finally {
clearTimeout(timeout);
controller.abort();
await reader?.cancel().catch(() => undefined);
reader?.releaseLock();
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
if (!line.trim()) return {};
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
return { error: 'Release builder frame is too large.' };
let message: unknown;
try {
message = JSON.parse(line) as RemoteBuildMessage;
message = JSON.parse(line);
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
if (message.event && onProgress) await onProgress(message.event);
return message;
}
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
fetchImpl: typeof fetch = fetch,
tokenFile?: string
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
@@ -1024,7 +1024,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
options.fetchImpl ?? fetch,
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
);
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
+15 -3
View File
@@ -22,6 +22,7 @@ import {
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
import { GameSessionLimitError } from './auth/sessionService.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
@@ -1048,11 +1049,13 @@ export const appRouter = router({
issueGameSession: procedure
.input(
z.object({
sessionToken: z.string().min(1),
sessionToken: z.string().min(1).max(256),
profile: zProfile,
})
)
.mutation(async ({ ctx, input }) => {
// 권한 snapshot보다 이후에 발생한 flush도 이 발급을 회수해야 한다.
const authorizationStartedAt = Date.now();
const gatewaySession = await ctx.sessions.getSession(input.sessionToken);
if (!gatewaySession) {
throw new TRPCError({
@@ -1089,14 +1092,23 @@ export const appRouter = router({
message: '카카오 인증 유예기간이 만료되었습니다. 인증 후 계속 이용할 수 있습니다.',
});
}
const gameSession = await ctx.sessions.createGameSession(input.sessionToken, input.profile);
const gameSession = await ctx.sessions
.createGameSession(input.sessionToken, input.profile)
.catch((error: unknown) => {
if (!(error instanceof GameSessionLimitError)) throw error;
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: '게임 세션이 많습니다. 기존 세션이 만료된 뒤 다시 시도해 주세요.',
});
});
if (!gameSession) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Session is not valid.',
});
}
const now = new Date();
// 발급 뒤 logout/flush가 겹쳐도 issuedAt을 나중 시각으로 다시 쓰지 않는다.
const now = new Date(Math.min(authorizationStartedAt, Date.parse(gameSession.issuedAt)));
const accountIcon = resolveEffectiveAccountIcon(user);
const accountIcons = await ctx.users.listIcons(user.id);
const payload = {
+26 -2
View File
@@ -1,3 +1,4 @@
import { safeHttpLoggerOptions, safeHttpErrorHandler, createApiOriginGuard } from '@sammo-ts/common';
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
@@ -24,6 +25,7 @@ import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
import { appRouter } from './router.js';
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
@@ -65,7 +67,23 @@ export const createGatewayApiServer = async () => {
sessionTtlSeconds: config.sessionTtlSeconds,
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
});
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(redis.client),
config.redisKeyPrefix,
config.gameTokenSecret
);
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
try {
await flushPublisher.initialize();
} catch {
await Promise.allSettled([
Promise.resolve().then(() => {
if (redis.client.isOpen) redis.client.destroy();
}),
postgres.disconnect(),
]);
throw new Error('Gateway session revocation initialization failed.');
}
const kakaoClient = new KakaoOAuthClient({
restKey: config.kakaoRestKey,
adminKey: config.kakaoAdminKey,
@@ -89,9 +107,12 @@ export const createGatewayApiServer = async () => {
config.defaultNavigationConfigFile
);
const app = fastify({
logger: true,
logger: safeHttpLoggerOptions,
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
trustProxy: false,
routerOptions: gatewayFastifyRouterOptions,
});
app.setErrorHandler(safeHttpErrorHandler);
const webPush = new WebPushCoordinator(
postgres.prisma as GatewayPrismaClient,
{
@@ -104,8 +125,9 @@ export const createGatewayApiServer = async () => {
(error) => app.log.error({ err: error }, 'web push delivery failed')
);
app.addHook('onRequest', createApiOriginGuard(config.corsAllowedOrigins));
await app.register(cors, {
origin: true,
origin: config.corsAllowedOrigins,
credentials: true,
});
await fs.mkdir(path.resolve(process.cwd(), config.userIconDir), { recursive: true });
@@ -152,6 +174,8 @@ export const createGatewayApiServer = async () => {
releases,
orchestrator,
profileStatus,
authBudget,
requestIp: req.ip,
requestHeaders: req.headers,
prisma: postgres.prisma as GatewayPrismaClient,
navigationConfig,
+35 -3
View File
@@ -1,8 +1,40 @@
import { initTRPC } from '@trpc/server';
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
import { initTRPC, TRPCError } from '@trpc/server';
import type { GatewayApiContext } from './context.js';
const t = initTRPC.context<GatewayApiContext>().create();
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
export const router = t.router;
export const procedure = t.procedure;
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
if (!isBudgetedAuthAction(path)) return next();
const raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => {
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (
(path.startsWith('account.') || path === 'auth.issueGameSession') &&
typeof input.sessionToken === 'string' &&
input.sessionToken.length <= 256
) {
const session = await ctx.sessions.getSession(input.sessionToken);
if (session) return `${path === 'auth.issueGameSession' ? 'game-session' : 'account'}:${session.userId}`;
}
return undefined;
};
try {
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
} catch (error) {
if (!(error instanceof AuthBudgetError)) throw error;
throw new TRPCError({
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
message:
error.reason === 'limited'
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
});
}
});
+4 -4
View File
@@ -1,3 +1,4 @@
import { sendBoundedNotification } from './sendNotification.js';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import webPush from 'web-push';
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
export interface WebPushCoordinatorConfig {
enabled: boolean;
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
try {
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
parsePushEndpoint(delivery.subscription.endpoint);
await webPush.sendNotification(
await sendBoundedNotification(
{
endpoint: delivery.subscription.endpoint,
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
body: delivery.notification.body,
url: delivery.notification.url,
tag: delivery.notification.tag,
}),
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
})
);
await this.prisma.$executeRaw(GatewayPrisma.sql`
UPDATE web_push_delivery
@@ -0,0 +1,99 @@
import { request, type RequestOptions } from 'node:https';
import type { ClientRequest, IncomingMessage } from 'node:http';
import webPush from 'web-push';
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
export class PushTransportError extends Error {
constructor(
message: string,
readonly statusCode = 0
) {
super(message);
}
}
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
export const sendPushRequest = (
details: webPush.RequestDetails,
requestImpl: PushRequest = request,
timeoutMs = 10_000
): Promise<void> =>
new Promise((resolve, reject) => {
const endpoint = parsePushEndpoint(details.endpoint);
let response: IncomingMessage | undefined;
let req: ClientRequest | undefined;
let settled = false;
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) {
response?.destroy();
req?.destroy();
reject(error);
} else resolve();
};
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
try {
req = requestImpl(
{
protocol: 'https:',
hostname: endpoint.hostname,
port: 443,
path: `${endpoint.pathname}${endpoint.search}`,
method: details.method,
headers: details.headers,
agent: publicPushAgent,
},
(incoming) => {
response = incoming;
if (settled) {
incoming.destroy();
return;
}
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
finish(new PushTransportError('Push service response exceeds its size limit.'));
return;
}
let bytes = 0;
incoming.on('data', (chunk: Buffer) => {
bytes += chunk.length;
if (bytes > MAX_PUSH_RESPONSE_BYTES)
finish(new PushTransportError('Push service response exceeds its size limit.'));
});
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
incoming.on('end', () => {
const status = incoming.statusCode ?? 0;
finish(
status >= 200 && status < 300
? undefined
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
);
});
}
);
req.on('error', (error) =>
finish(
error instanceof PushEndpointPolicyError
? error
: new PushTransportError('Push service request failed.')
)
);
req.end(details.body ?? undefined);
} catch {
finish(new PushTransportError('Push service request failed.'));
}
});
export const sendBoundedNotification = async (
subscription: webPush.PushSubscription,
payload: string
): Promise<void> => {
parsePushEndpoint(subscription.endpoint);
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
await sendPushRequest(details);
};
@@ -1,10 +1,11 @@
import { z } from 'zod';
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import fastify, { type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { trpcJsonBodyHttpServerOptions, createApiOriginGuard, resolveApiAllowedOrigins } from '@sammo-ts/common';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
@@ -93,6 +94,9 @@ const createHarness = async (adminRoles = ['user', 'admin.users.manage', 'admin.
const targetSession = await sessions.createSession(target);
const flushes: Array<{ userId: string; reason?: string }> = [];
const app = fastify({ logger: false });
const origins = resolveApiAllowedOrigins(undefined, ['https://sam.hided.net/gateway/']);
app.addHook('onRequest', createApiOriginGuard(origins));
await app.register(cors, { origin: origins, credentials: true });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
@@ -176,6 +180,29 @@ const postTrpc = async (
};
describe('admin security over HTTP transport', () => {
it('rejects an untrusted browser origin before a valid session can log out', async () => {
const harness = await createHarness();
for (const origin of ['https://untrusted.example', 'null']) {
const response = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-session-token': harness.adminSessionToken, origin },
body: JSON.stringify({ sessionToken: harness.adminSessionToken }),
});
expect(response.status).toBe(403);
expect(response.headers.get('access-control-allow-origin')).toBeNull();
expect((await postTrpc(harness.baseUrl, 'me', null, harness.adminSessionToken)).response.status).toBe(200);
}
const allowed = await fetch(`${harness.baseUrl}/trpc/auth.logout`, {
method: 'OPTIONS',
headers: {
origin: 'https://sam.hided.net',
'access-control-request-method': 'POST',
'access-control-request-headers': 'x-session-token,content-type',
},
});
expect(allowed.status).toBe(204);
expect(allowed.headers.get('access-control-allow-origin')).toBe('https://sam.hided.net');
});
it('protects runtime diagnostics at the HTTP authentication and profile scope boundaries', async () => {
const harness = await createHarness(['admin.profiles.runtime:che:default']);
const input = { profileName: 'che:default' };
@@ -0,0 +1,53 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-test:${randomUUID()}`;
const store = new RedisAuthCounterStore(client);
const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
await client.del(keys);
await client.quit();
});
it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => {
const attempts = await Promise.all(
Array.from({ length: 100 }, () =>
store.consume([
{ key: keys[0]!, limit: 10, windowMs: 60_000 },
{ key: keys[1]!, limit: 1000, windowMs: 60_000 },
])
)
);
expect(attempts.filter((retry) => retry === 0)).toHaveLength(10);
expect(attempts.filter((retry) => retry > 0)).toHaveLength(90);
expect(await client.get(keys[0]!)).toBe('10');
expect(await client.get(keys[1]!)).toBe('10');
expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0);
});
it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => {
const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }];
expect(await store.consume(counters)).toBe(0);
const before = await client.pTTL(keys[2]!);
expect(await store.consume(counters)).toBeGreaterThan(0);
expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before);
await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0);
expect(await store.consume(counters)).toBe(0);
});
it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => {
await client.set(keys[3]!, '10');
await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({
reason: 'unavailable',
});
});
});
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
describe('authentication attempt and resource budgets', () => {
afterEach(() => {
vi.useRealTimers();
});
it('shares a login subject across clients without exposing account or address in keys', async () => {
const store = new InMemoryAuthCounterStore();
const consume = vi.spyOn(store, 'consume');
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
for (let i = 0; i < 10; i++)
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
const work = vi.fn(async () => 'must not run');
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
reason: 'limited',
retryAfterSeconds: 60,
});
expect(work).not.toHaveBeenCalled();
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
'ok'
);
});
it('does not extend a blocked account window and admits it after expiry', async () => {
vi.useFakeTimers();
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
await vi.advanceTimersByTimeAsync(59_000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
retryAfterSeconds: 1,
});
await vi.advanceTimersByTimeAsync(1000);
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
'recovered'
);
});
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
reason: 'limited',
});
});
it('bounds expensive work and releases capacity after resolver failures', async () => {
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const work = vi.fn(async () => {
await pending;
throw new Error('fixture resolver failure');
});
const running = Array.from({ length: 4 }, () =>
budget.run('auth.login', 'client', undefined, work).catch(() => null)
);
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
const rejected = vi.fn(async () => null);
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
AuthBudgetError
);
expect(rejected).not.toHaveBeenCalled();
release();
await Promise.all(running);
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
});
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
vi.useFakeTimers();
let release!: (retry: number) => void;
const waiting = new Promise<number>((resolve) => {
release = resolve;
});
const store = { consume: vi.fn(() => waiting) };
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
const work = vi.fn(async () => 'ok');
const requests = Array.from({ length: 32 }, () =>
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
);
await vi.advanceTimersByTimeAsync(2001);
expect(
(await Promise.all(requests)).every(
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
)
).toBe(true);
const actorLookup = vi.fn(async () => undefined);
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
reason: 'unavailable',
});
expect(actorLookup).not.toHaveBeenCalled();
expect(work).not.toHaveBeenCalled();
expect(store.consume).toHaveBeenCalledTimes(32);
release(0);
await Promise.resolve();
await Promise.resolve();
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
expect(work).toHaveBeenCalledTimes(1);
});
it('fails closed before credential work when the counter backend errors or stalls', async () => {
const work = vi.fn(async () => null);
const failing = new AuthAttemptBudget(
{
consume: async () => {
throw new Error('private-backend-error');
},
},
'fixture',
'secret'
);
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
reason: 'unavailable',
});
vi.useFakeTimers();
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(2001);
expect(await result).toMatchObject({ reason: 'unavailable' });
expect(work).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,238 @@
import { constants, publicEncrypt, randomUUID } from 'node:crypto';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { createGatewayApiContext } from '../src/context.js';
import { appRouter } from '../src/router.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => {
const client = createClient({ url: redisUrl });
const prefix = `auth-budget-http-test:${randomUUID()}`;
const envelope = createPasswordEnvelopeService();
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
const keys: string[] = [];
for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page);
if (keys.length) await client.del(keys);
await client.quit();
});
const credential = (password: string) => {
const key = envelope.getPublicKey();
return {
keyId: key.keyId,
ciphertext: publicEncrypt(
{ key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(password)
).toString('base64'),
};
};
const fixture = async (available = true) => {
const users = createInMemoryUserRepository();
await users.createUser({ username: 'target-user', password: 'correct-password' });
await users.createUser({ username: 'other-user', password: 'correct-password' });
const requestIps = new Set<string>();
const peer = (request: FastifyRequest): string => {
requestIps.add(request.ip);
return request.ip;
};
const verify = vi.spyOn(users, 'verifyPassword');
const open = vi.fn(envelope.open);
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 });
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(available ? client : client.duplicate()),
`${prefix}:${randomUUID()}`,
'synthetic-budget-key'
);
const app = fastify({ trustProxy: false });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({
users,
sessions,
passwordEnvelope: { ...envelope, open },
authBudget,
requestIp: peer(req),
requestHeaders: req.headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'synthetic-game-key',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: true,
localRegistrationEnabled: true,
localAccountGraceDays: 7,
profiles: {} as never,
orchestrator: {} as never,
profileStatus: {} as never,
prisma: {} as never,
}),
},
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (operation: string, input: unknown, headers: Record<string, string> = {}) =>
fetch(`${address}/trpc/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(input),
});
return { app, address, post, users, sessions, verify, open, requestIps };
};
it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => {
const f = await fixture();
try {
for (let i = 0; i < 10; i++) {
const response = await f.post(
'auth.login',
{ username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') },
{ 'x-forwarded-for': `198.51.100.${i + 1}` }
);
expect(response.status).toBe(401);
}
expect(f.requestIps.size).toBe(1);
expect(f.requestIps.has('127.0.0.1')).toBe(true);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
const denied = await f.post(
'auth.login',
{ username: 'target-user', credential: credential('correct-password') },
{ 'x-forwarded-for': '203.0.113.99' }
);
expect(denied.status).toBe(429);
expect(JSON.stringify(await denied.json())).not.toContain('stack');
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect(
(await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
await f.app.close();
}
});
it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => {
const f = await fixture();
try {
const statuses: number[] = [];
for (let round = 0; round < 4; round++) {
const input = Object.fromEntries(
Array.from({ length: 3 }, (_value, i) => [
String(i),
{ username: 'target-user', credential: credential('wrong-password') },
])
);
const response = await f.post('auth.login,auth.login,auth.login?batch=1', input);
const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>;
statuses.push(...body.map((entry) => entry.error.data.httpStatus));
}
expect(statuses.filter((status) => status === 401)).toHaveLength(10);
expect(statuses.filter((status) => status === 429)).toHaveLength(2);
expect(f.verify).toHaveBeenCalledTimes(10);
} finally {
await f.app.close();
}
});
it('shares the password/deletion subject across sessions of the same authenticated actor', async () => {
const f = await fixture();
const user = await f.users.findByUsername('target-user');
try {
for (let i = 0; i < 10; i++) {
const session = await f.sessions.createSession(user!);
const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('wrong-password'),
newCredential: credential('new-password'),
});
expect(response.status).toBe(401);
}
const session = await f.sessions.createSession(user!);
expect(
(
await f.post('account.scheduleDeletion', {
sessionToken: session.sessionToken,
currentCredential: credential('correct-password'),
})
).status
).toBe(429);
expect(f.verify).toHaveBeenCalledTimes(10);
expect(f.open).toHaveBeenCalledTimes(10);
expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined();
} finally {
await f.app.close();
}
});
it('fails closed on unavailable protection while keeping logout independent of its counters', async () => {
const f = await fixture(false);
try {
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(503);
expect(f.open).not.toHaveBeenCalled();
expect(f.verify).not.toHaveBeenCalled();
const user = await f.users.findByUsername('target-user');
const session = await f.sessions.createSession(user!);
expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200);
expect(await f.sessions.getSession(session.sessionToken)).toBeNull();
expect(
(await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status
).toBe(503);
const malformed = await fetch(`${f.address}/trpc/auth.login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{',
});
expect(malformed.status).toBe(400);
} finally {
await f.app.close();
}
});
it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => {
const f = await fixture();
let release!: () => void;
const waiting = new Promise<void>((resolve) => {
release = resolve;
});
f.verify.mockImplementation(async () => {
await waiting;
return false;
});
const input = { username: 'target-user', credential: credential('wrong-password') };
const pending = Array.from({ length: 4 }, () => f.post('auth.login', input));
try {
await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4));
expect((await f.post('auth.login', input)).status).toBe(429);
expect(f.open).toHaveBeenCalledTimes(4);
release();
expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]);
f.verify.mockRestore();
expect(
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
.status
).toBe(200);
} finally {
release();
await Promise.allSettled(pending);
await f.app.close();
}
});
});
+93
View File
@@ -1242,6 +1242,59 @@ describe('gateway auth flow', () => {
expect(validated?.user.username).toBe('tester');
});
it('starts token age before the user snapshot so a concurrent role flush can revoke stale identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'role-snapshot-race', password: 'synthetic-password' });
const parent = await sessions.createSession(user);
const issuedAt = new Date().toISOString();
vi.useFakeTimers({ now: new Date(issuedAt) });
const original = users.findById.bind(users);
const find = vi.spyOn(users, 'findById').mockImplementationOnce(async (id) => {
const snapshot = await original(id);
vi.advanceTimersByTime(5000); // admin mutation/flush arrives after the snapshot.
return snapshot;
});
try {
const result = await caller.auth.issueGameSession({
sessionToken: parent.sessionToken,
profile: 'che:default',
});
expect(decryptGameSessionToken(result.gameToken, 'test-secret')?.issuedAt).toBe(issuedAt);
} finally {
find.mockRestore();
vi.useRealTimers();
}
});
it('retains the original issuance instant when logout flush overlaps later identity reads', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({ username: 'issue-flush-race', password: 'synthetic-password' });
const parent = await sessions.createSession(user);
const issuedAt = new Date().toISOString();
vi.useFakeTimers({ now: new Date(issuedAt) });
const create = vi.spyOn(sessions, 'createGameSession');
const original = sessions.createGameSession.bind(sessions);
create.mockImplementationOnce(async (...args) => {
// Snapshot admitted before the subsequent logout/flush instant.
const game = await original(...args);
vi.advanceTimersByTime(5_000);
await sessions.revokeSession(parent.sessionToken);
return game;
});
try {
const issued = await caller.auth.issueGameSession({
sessionToken: parent.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.issuedAt).toBe(issuedAt);
expect(payload?.expiresAt).toBe(new Date(Date.parse(issuedAt) + 600_000).toISOString());
} finally {
create.mockRestore();
vi.useRealTimers();
}
});
it('keeps the migrated member number inside the encrypted game identity', async () => {
const { caller, users, sessions } = buildCaller();
const user = await users.createUser({
@@ -1474,6 +1527,46 @@ describe('account self service', () => {
}
});
it('bounds pending icon uploads without changing the owned library limit', async () => {
const { caller, users, sessions, userIconUpload } = buildCaller();
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
const session = await sessions.createSession(user);
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
.png()
.toBuffer();
const input = {
sessionToken: session.sessionToken,
imageData: `data:image/png;base64,${png.toString('base64')}`,
};
const pending: Array<() => void> = [];
userIconUpload.upload.mockImplementation(
({ filename }) =>
new Promise((resolve) => {
pending.push(() =>
resolve({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
})
);
})
);
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
try {
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
} finally {
pending.forEach((done) => done());
await Promise.all(admitted);
}
expect(await users.listIcons(user.id)).toHaveLength(4);
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
picture: `users/core2026/${filename}`,
publicUrl: `https://image.example/${filename}`,
}));
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
expect(await users.listIcons(user.id)).toHaveLength(5);
});
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
try {
@@ -0,0 +1,79 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
buildSessionRevocationBaselineKey,
buildUserSessionRevocationKey,
} from '@sammo-ts/common/auth/sessionRevocation';
import { RedisGatewayFlushPublisher } from '../src/auth/flushPublisher.js';
const url = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
describe.skipIf(!url)('persistent Gateway flush over real Redis', () => {
const client = createClient({ url });
const channel = `revocation-publisher-test:${randomUUID()}:flush`;
const publisher = new RedisGatewayFlushPublisher(client, channel);
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
for await (const keys of client.scanIterator({ MATCH: `${channel}:*`, COUNT: 100 }))
if (keys.length) await client.del(keys);
await client.quit();
});
it('initializes once and preserves the original cutoff across publisher restarts', async () => {
await publisher.initialize();
const baseline = await client.get(buildSessionRevocationBaselineKey(channel));
expect(Number(baseline)).toBeGreaterThan(0);
await new RedisGatewayFlushPublisher(client, channel).initialize();
expect(await client.get(buildSessionRevocationBaselineKey(channel))).toBe(baseline);
expect(await client.pTTL(buildSessionRevocationBaselineKey(channel))).toBe(-1);
});
it('persists a revocation when there are zero subscribers', async () => {
const userId = randomUUID();
await publisher.publishUserFlush(userId, 'logout');
expect(Number(await client.get(buildUserSessionRevocationKey(channel, userId)))).toBeGreaterThan(0);
expect(await client.pTTL(buildUserSessionRevocationKey(channel, userId))).toBe(-1);
});
it('makes the watermark readable before delivering flush metadata', async () => {
const subscriber = client.duplicate();
await subscriber.connect();
const userId = randomUUID();
let delivered: { event: Record<string, unknown>; persisted: string | null } | undefined;
await subscriber.subscribe(channel, async (raw) => {
delivered = {
event: JSON.parse(raw) as Record<string, unknown>,
persisted: await client.get(buildUserSessionRevocationKey(channel, userId)),
};
});
try {
await publisher.publishUserFlush(userId, 'identity-updated', {
displayName: 'fixture',
identityRevision: 'revision-1',
});
await vi.waitFor(() => expect(delivered).toBeDefined());
expect(delivered!.event).toMatchObject({
userId,
reason: 'identity-updated',
displayName: 'fixture',
identityRevision: 'revision-1',
});
expect(Number(delivered!.persisted)).toBe(Date.parse(delivered!.event.flushedAt as string));
} finally {
await subscriber.unsubscribe(channel);
await subscriber.quit();
}
});
it('never moves a newer watermark backwards under late or concurrent flushes', async () => {
const userId = randomUUID();
const future = String(Date.now() + 60_000);
await client.set(buildUserSessionRevocationKey(channel, userId), future);
await Promise.all(Array.from({ length: 8 }, () => publisher.publishUserFlush(userId, 'concurrent')));
expect(await client.get(buildUserSessionRevocationKey(channel, userId))).toBe(future);
});
it('fails closed for an unavailable client instead of publishing without storage', async () => {
const offline = client.duplicate();
const closed = new RedisGatewayFlushPublisher(offline, channel);
await expect(closed.initialize()).rejects.toThrow('unavailable');
await expect(closed.publishUserFlush(randomUUID(), 'logout')).rejects.toThrow('unavailable');
});
});
@@ -0,0 +1,238 @@
import { randomUUID } from 'node:crypto';
import fastify, { type FastifyRequest } from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
import { appRouter } from '../src/router.js';
import { createGatewayApiContext } from '../src/context.js';
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { RedisGatewaySessionService } from '../src/auth/redisSessionService.js';
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
const redisUrl = process.env.GATEWAY_SESSION_REDIS_TEST_URL;
const limit = 256;
const user = async () => {
const users = createInMemoryUserRepository();
return users.createUser({ username: `session-${randomUUID()}`, password: 'synthetic-test-password' });
};
describe.skipIf(!redisUrl)('game session lifecycle over real Redis', () => {
const client = createClient({ url: redisUrl });
const prefix = `game-session-security-test:${randomUUID()}`;
const store = new RedisGatewaySessionService(client, {
keyPrefix: prefix,
sessionTtlSeconds: 600,
gameSessionTtlSeconds: 300,
});
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
for await (const keys of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) {
if (keys.length) await client.del(keys);
}
await client.quit();
});
const keys = (token: string) => ({
parent: `${prefix}:session:${token}`,
games: `${prefix}:session-games:${token}`,
});
const httpFixture = async () => {
const users = createInMemoryUserRepository();
const first = await users.createUser({ username: `first-${randomUUID()}`, password: 'synthetic-password' });
const second = await users.createUser({ username: `second-${randomUUID()}`, password: 'synthetic-password' });
const parents = await Promise.all([
store.createSession(first),
store.createSession(first),
store.createSession(second),
]);
const profiles = { getProfile: vi.fn(async () => null) };
const authBudget = new AuthAttemptBudget(
new RedisAuthCounterStore(client),
`${prefix}:${randomUUID()}`,
'synthetic-rate-key'
);
const passwordEnvelope = createPasswordEnvelopeService();
const app = fastify({ trustProxy: false });
await app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
...trpcJsonBodyHttpServerOptions,
createContext: ({ req }: { req: FastifyRequest }) =>
createGatewayApiContext({
users,
sessions: store,
passwordEnvelope,
authBudget,
requestIp: req.ip,
requestHeaders: req.headers,
flushPublisher: { publishUserFlush: async () => {} },
gameTokenSecret: 'synthetic-game-key',
gameSessionTtlSeconds: 300,
kakaoClient: {} as never,
oauthSessions: {} as never,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: true,
localRegistrationEnabled: true,
localAccountGraceDays: 7,
profiles: profiles as never,
orchestrator: {} as never,
profileStatus: {} as never,
prisma: {} as never,
}),
},
});
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (operation: string, input: unknown) =>
fetch(`${address}/trpc/${operation}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
const issue = (index: number) =>
post('auth.issueGameSession', { sessionToken: parents[index]!.sessionToken, profile: 'che:default' });
return { app, post, issue, parents, profiles };
};
it('limits real HTTP issuance by account across login sessions before profile/icon reads', async () => {
const f = await httpFixture();
try {
for (let i = 0; i < 32; i++) expect((await f.issue(i % 2)).status).toBe(200);
const denied = await f.issue(1);
expect(denied.status).toBe(429);
expect(f.profiles.getProfile).toHaveBeenCalledTimes(32);
expect(JSON.stringify(await denied.json())).not.toContain('stack');
expect((await f.issue(2)).status).toBe(200);
expect(
(await f.post('auth.issueGameSession', { sessionToken: 'unknown', profile: 'che:default' })).status
).toBe(401);
} finally {
await f.app.close();
}
});
it('maps the live record cap to HTTP 429 without invalidating existing tokens', async () => {
const f = await httpFixture();
const parent = f.parents[0]!;
try {
const first = await store.createGameSession(parent.sessionToken, 'che:default');
for (let i = 1; i < limit; i++) await store.createGameSession(parent.sessionToken, 'che:default');
expect((await f.issue(0)).status).toBe(429);
expect(await store.getGameSession('che:default', first!.gameToken)).toEqual(first);
expect((await f.post('auth.logout', { sessionToken: parent.sessionToken })).status).toBe(200);
expect((await f.issue(0)).status).toBe(401);
expect(await client.sCard(keys(parent.sessionToken).games)).toBe(0);
} finally {
await f.app.close();
}
});
it('preserves game records when explicitly revoking only the parent', async () => {
const parent = await store.createSession(await user());
const game = await store.createGameSession(parent.sessionToken, 'che:default');
await store.revokeSession(parent.sessionToken, { revokeGames: false });
expect(await store.getSession(parent.sessionToken)).toBeNull();
expect(await store.getGameSession('che:default', game!.gameToken)).toEqual(game);
});
it('atomically admits exactly 256 live records across concurrent profile requests', async () => {
const session = await store.createSession(await user());
const outcomes = await Promise.allSettled(
Array.from({ length: limit + 32 }, (_, i) =>
store.createGameSession(session.sessionToken, i % 2 ? 'che:default' : 'hwe:default')
)
);
expect(outcomes.filter((x) => x.status === 'fulfilled' && x.value)).toHaveLength(limit);
expect(outcomes.filter((x) => x.status === 'rejected')).toHaveLength(32);
expect(await client.sCard(keys(session.sessionToken).games)).toBe(limit);
});
it('removes expired membership before admitting a new game session', async () => {
const session = await store.createSession(await user());
const key = keys(session.sessionToken).games;
const expiredKeys = Array.from({ length: limit }, (_, i) => `${prefix}:game-session:che:default:expired-${i}`);
await client.sAdd(key, expiredKeys);
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
expect(await client.sCard(key)).toBe(1);
});
it('does not revive a parent revoked after its snapshot was read', async () => {
const session = await store.createSession(await user());
const get = vi.spyOn(store, 'getSession');
get.mockImplementationOnce(async () => {
await store.revokeSession(session.sessionToken);
return session;
});
try {
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
expect(await client.exists(keys(session.sessionToken).games)).toBe(0);
} finally {
get.mockRestore();
}
});
it('preserves independent game TTL without resetting the index to a fresh parent TTL', async () => {
const session = await store.createSession(await user());
const { parent, games } = keys(session.sessionToken);
await client.pExpire(parent, 1000);
const game = await store.createGameSession(session.sessionToken, 'che:default');
expect(game).not.toBeNull();
expect(await client.pTTL(games)).toBeGreaterThan(295_000);
expect(await client.pTTL(games)).toBeLessThanOrEqual(300_000);
});
it('revokes existing oversized indexes in bounded batches while preserving other parents', async () => {
const session = await store.createSession(await user());
const other = await store.createSession(await user());
const gameKeys = Array.from({ length: limit + 9 }, (_, i) => `${prefix}:game-session:che:default:legacy-${i}`);
const transaction = client.multi();
for (const key of gameKeys) transaction.set(key, '{}', { EX: 300 });
transaction.sAdd(keys(session.sessionToken).games, gameKeys);
await transaction.exec();
await expect(store.createGameSession(session.sessionToken, 'che:default')).rejects.toThrow(
'Game session limit'
);
await store.revokeSession(session.sessionToken);
expect(
await client.exists([keys(session.sessionToken).parent, keys(session.sessionToken).games, ...gameKeys])
).toBe(0);
expect(await store.getSession(other.sessionToken)).not.toBeNull();
});
});
describe('in-memory game session lifecycle', () => {
it('enforces the same live cap and reclaims expired records', async () => {
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 1 });
const session = await store.createSession(await user());
try {
vi.useFakeTimers();
for (let i = 0; i < limit; i++)
expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull();
await expect(store.createGameSession(session.sessionToken, 'hwe:default')).rejects.toThrow(
'Game session limit'
);
vi.advanceTimersByTime(1001);
expect(await store.createGameSession(session.sessionToken, 'hwe:default')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
it('does not issue after a concurrent revoke', async () => {
const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 300 });
const session = await store.createSession(await user());
const get = vi.spyOn(store, 'getSession').mockImplementationOnce(async () => {
await store.revokeSession(session.sessionToken);
return session;
});
try {
expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull();
} finally {
get.mockRestore();
}
});
});
@@ -0,0 +1,51 @@
import fastify from 'fastify';
import { TRPCError } from '@trpc/server';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { describe, expect, it } from 'vitest';
import { router as gatewayRouter, procedure as gatewayProcedure } from '../src/trpc.js';
const internalFailure = () => {
throw new Error('synthetic-db-secret', { cause: new Error('synthetic-cause-secret') });
};
const badInput = () => {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '잘못된 입력입니다.',
cause: new Error('synthetic-private-cause'),
});
};
describe('public HTTP error privacy', () => {
it('the real router factory hides internal errors and all stacks while preserving validation messages', async () => {
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway',
trpcOptions: {
router: gatewayRouter({
internal: gatewayProcedure.query(internalFailure),
invalid: gatewayProcedure.query(badInput),
}),
createContext: () => ({}),
},
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
for (const prefix of ['gateway']) {
for (const procedure of ['internal', 'invalid']) {
const response = await fetch(`${address}/${prefix}/${procedure}`);
const body = (await response.json()) as {
error: { message: string; data: { code: string; stack?: string } };
};
expect(response.status).toBe(procedure === 'internal' ? 500 : 400);
expect(body.error.message).toBe(
procedure === 'internal' ? 'Internal server error' : '잘못된 입력입니다.'
);
expect(body.error.data.stack).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('synthetic-');
}
}
} finally {
await app.close();
}
});
});
+11 -8
View File
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
await expect(createClient().signup('access-token')).resolves.toEqual({
alreadyRegistered: true,
});
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
headers: {
Authorization: 'Bearer access-token',
},
});
expect(fetchMock).toHaveBeenCalledWith(
new URL('https://kapi.example.test/v1/user/signup'),
expect.objectContaining({
headers: {
Authorization: 'Bearer access-token',
},
redirect: 'error',
signal: expect.any(AbortSignal),
})
);
});
it('continues to reject unrelated Kakao signup errors', async () => {
@@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => {
})
);
await expect(createClient().signup('access-token')).rejects.toThrow(
'Kakao signup error: {"msg":"invalid request","code":-201}'
);
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
});
});
@@ -0,0 +1,95 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { gzipSync } from 'node:zlib';
import { afterEach, describe, expect, it } from 'vitest';
import { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
describe('bounded Kakao provider transport', () => {
const servers: Server[] = [];
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => {
const server = createServer((_request, response) => respond(response));
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture provider listener failed.');
const origin = `http://127.0.0.1:${address.port}`;
return new KakaoOAuthClient({
restKey: 'synthetic-key',
redirectUri: `${origin}/callback`,
apiHost: origin,
oauthHost: origin,
requestTimeoutMs,
});
};
it('retains successful token/account responses and documented recovery responses', async () => {
const client = await provider((res) =>
res.end(
JSON.stringify({
id: 42,
access_token: 'synthetic-token',
expires_in: 3600,
kakao_account: {
has_email: true,
email: 'fixture@example.test',
is_email_valid: true,
is_email_verified: true,
},
})
)
);
expect(await client.exchangeCode('synthetic-code')).toMatchObject({
accessToken: 'synthetic-token',
accessTokenExpiresIn: 3600,
});
expect(await client.getMe('synthetic-token')).toMatchObject({
id: '42',
kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true },
});
});
it('aborts both header and response-body stalls within the configured deadline', async () => {
const silent = await provider(() => {}, 100);
await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const partial = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.write('{');
}, 100);
await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => {
const oversized = await provider((res) => {
res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' });
res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) })));
});
await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data'));
await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
});
it('does not follow redirects or expose provider error details', async () => {
let destinationRequests = 0;
const destination = await provider((res) => {
destinationRequests += 1;
res.end('{}');
});
const destinationUrl = new URL(destination.buildAuthUrl('state', []));
const redirect = await provider((res) => {
res.writeHead(302, { location: destinationUrl.origin });
res.end();
});
await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
expect(destinationRequests).toBe(0);
const failure = await provider((res) => {
res.writeHead(400);
res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 }));
});
await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.');
});
});
+17 -2
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RemoteBuildRunner,
@@ -34,12 +37,24 @@ describe('sanitizeReleaseBuildEnv', () => {
});
describe('RemoteBuildRunner', () => {
let directory: string;
let tokenFile: string;
beforeAll(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builder-token-test-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterAll(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('streams progress and returns the builder result without sending secrets', async () => {
const messages = [
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
{ result: { ok: true, exitCode: 0, output: 'building' } },
];
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
expect(init?.redirect).toBe('error');
const request = JSON.parse(String(init?.body)) as {
commands: Array<{ env: Record<string, string> }>;
};
@@ -49,7 +64,7 @@ describe('RemoteBuildRunner', () => {
});
}) as unknown as typeof fetch;
const progress: BuildProgressEvent[] = [];
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl, { tokenFile }).run(
[
{
command: 'pnpm',
@@ -0,0 +1,114 @@
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RemoteBuildRunner } from '../src/orchestrator/buildRunner.js';
describe('release builder authenticated HTTP and bounded responses', () => {
let directory: string;
let tokenFile: string;
const servers: Server[] = [];
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-build-http-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await fs.rm(directory, { recursive: true, force: true });
});
const provider = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
const server = createServer(handler);
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture listener unavailable.');
return `http://127.0.0.1:${address.port}`;
};
const commands = [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/srv/core/repository' }];
it('sends the file token only as a header and preserves successful stream results', async () => {
let received = '';
const endpoint = await provider((req, res) => {
expect(req.headers['x-release-builder-token']).toBe('t'.repeat(64));
req.on('data', (chunk: Buffer) => {
received += chunk.toString();
});
req.on('end', () => res.end(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: 'done' } })}\n`));
});
expect(await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).toMatchObject({
ok: true,
output: 'done',
});
expect(received).not.toContain('t'.repeat(64));
});
it('fails closed without a valid credential before sending a request', async () => {
let requests = 0;
const endpoint = await provider((_req, res) => {
requests += 1;
res.end();
});
const result = await new RemoteBuildRunner(endpoint).run(commands);
expect(result.ok).toBe(false);
expect(requests).toBe(0);
expect(result.output).not.toContain(directory);
});
it('rejects malformed and oversized progress without exposing upstream error payloads', async () => {
for (const body of [
'{"event":{"type":"OUTPUT","stream":"stdout","message":42}}\n',
'x'.repeat(512 * 1024 + 1),
'null\n',
'{"result":{"ok":true,"exitCode":2,"output":""}}\n',
]) {
const endpoint = await provider((_req, res) => res.end(body));
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
}
const endpoint = await provider((_req, res) => {
res.writeHead(500);
res.end('synthetic-sensitive-detail'.repeat(100_000));
});
const result = await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands);
expect(result.output).toBe('Release builder returned HTTP 500.');
const rejected = await provider((_req, res) =>
res.end(JSON.stringify({ error: 'synthetic-sensitive-detail' }) + '\n')
);
expect((await new RemoteBuildRunner(rejected, fetch, { tokenFile }).run(commands)).output).toBe(
'Release builder rejected the build request.'
);
});
it('does not forward the token to redirect destinations', async () => {
let requests = 0;
const destination = await provider((_req, res) => {
requests += 1;
res.end();
});
const endpoint = await provider((_req, res) => {
res.writeHead(307, { location: destination });
res.end();
});
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
expect(requests).toBe(0);
});
it('bounds header/body stalls and preserves explicit operator cancellation', async () => {
const silent = await provider(() => {});
expect((await new RemoteBuildRunner(silent, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const partial = await provider((_req, res) => {
res.writeHead(200);
res.write('{');
});
expect((await new RemoteBuildRunner(partial, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const controller = new AbortController();
const pending = new RemoteBuildRunner(partial, fetch, { tokenFile }).run(commands, undefined, {
signal: controller.signal,
});
controller.abort();
expect(await pending).toMatchObject({ ok: false, aborted: true, output: 'Build cancelled by operator.' });
});
});
@@ -40,9 +40,7 @@ describe('remote user icon store', () => {
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
const digest = createHash('sha256').update(body).digest('hex');
const expected = createHmac('sha256', secret)
.update(
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
)
.update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`)
.digest('hex');
expect(headers['x-image-signature']).toBe(expected);
});
@@ -58,4 +56,15 @@ describe('remote user icon store', () => {
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
).rejects.toThrow('HTTP 401');
});
it('rejects oversized upload response JSON before trusting its path', async () => {
const filename = `${'d'.repeat(32)}.png`;
const store = new RemoteUserIconStore(
'https://image.example',
'https://image.example/public',
'synthetic-secret',
async () =>
new Response(JSON.stringify({ path: `icons/users/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
);
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
});
});
@@ -1,3 +1,4 @@
import * as pushSender from '../src/webPush/sendNotification.js';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import webPush from 'web-push';
@@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => {
notificationId: notification.id,
},
});
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 });
const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 });
try {
coordinator.start();
await vi.waitFor(
@@ -0,0 +1,98 @@
import { Agent, request as httpsRequest } from 'node:https';
import { createPublicPushLookup, PushEndpointPolicyError } from '../src/webPush/endpointPolicy.js';
import { createServer, request, type Server } from 'node:http';
import { once } from 'node:events';
import { describe, expect, it } from 'vitest';
import { sendPushRequest } from '../src/webPush/sendNotification.js';
const details = {
endpoint: 'https://push.example/subscription',
method: 'POST' as const,
headers: {},
body: Buffer.from('encrypted'),
};
const listen = async (server: Server) => {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return (server.address() as { port: number }).port;
};
const close = (server: Server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
const transport =
(port: number): Parameters<typeof sendPushRequest>[1] =>
(options, callback) =>
request({ ...options, protocol: 'http:', hostname: '127.0.0.1', port, agent: undefined }, callback);
describe('bounded WebPush response transport', () => {
it('accepts normal success and preserves terminal HTTP status without retaining sensitive response bodies', async () => {
let status = 201;
const server = createServer((_, res) => {
res.writeHead(status);
res.end('private upstream details');
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).resolves.toBeUndefined();
status = 410;
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({
statusCode: 410,
message: 'Push service returned HTTP 410.',
});
} finally {
await close(server);
}
});
it.each([true, false])('rejects oversized response with declared length %s', async (declared) => {
const server = createServer((_, res) => {
if (declared) res.setHeader('content-length', 20_000);
res.end(Buffer.alloc(20_000));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toThrow('size limit');
} finally {
await close(server);
}
});
it('ends a continuously dripping response on the whole exchange deadline', async () => {
const server = createServer((_, res) => {
const timer = setInterval(() => res.write('x'), 10);
res.on('close', () => clearInterval(timer));
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port), 100)).rejects.toThrow('timed out');
} finally {
await close(server);
}
});
it('preserves terminal public-address policy failures from actual socket lookup', async () => {
const agent = new Agent({ lookup: createPublicPushLookup(async () => [{ address: '127.0.0.1', family: 4 }]) });
try {
await expect(
sendPushRequest(details, (options, callback) => httpsRequest({ ...options, agent }, callback))
).rejects.toBeInstanceOf(PushEndpointPolicyError);
} finally {
agent.destroy();
}
});
it('does not follow redirects', async () => {
let calls = 0;
const server = createServer((_, res) => {
calls++;
res.writeHead(307, { location: 'https://elsewhere.example' });
res.end();
});
const port = await listen(server);
try {
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({ statusCode: 307 });
expect(calls).toBe(1);
} finally {
await close(server);
}
});
});