운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user