SPB Git

Repository Public

rate-limit.test.ts 5.5 KB 96 lines typescript

1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { loadConfig } from './config.js';3import { Client, ORIGIN, PASSWORD, createHarness, setupAdmin, type Harness } from './testkit/harness.js';45let h: Harness;6let admin: Client;78const LIMITS = { api: 30, preAuth: 5, status: 12, health: 40 };9const get = (url: string, remoteAddress: string, cookie?: string) =>10  h.server.app.inject({ method: 'GET', url: `/api/v1${url}`, remoteAddress, headers: cookie ? { cookie } : {} });11const postJson = (url: string, remoteAddress: string) =>12  h.server.app.inject({ method: 'POST', url: `/api/v1${url}`, remoteAddress, headers: { origin: ORIGIN, 'content-type': 'application/json' }, payload: '{}' });13async function statuses(n: number, fn: () => Promise<{ statusCode: number }>): Promise<number[]> {14  const out: number[] = [];15  for (let i = 0; i < n; i++) out.push((await fn()).statusCode);16  return out;17}1819beforeAll(async () => {20  // Generous while the administrator is created, then the limits under test (read per request).21  h = await createHarness();22  ({ client: admin } = await setupAdmin(h));23  Object.assign(h.server.ctx.config.rateLimit, LIMITS);24});25afterAll(async () => {26  await h.close();27});2829describe('rate limit buckets', () => {30  it('defaults: page loads and health checks are not on the login budget', () => {31    const c = loadConfig({ NODE_ENV: 'test', AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws' });32    expect(c.rateLimit).toEqual({ api: 600, preAuth: 20, status: 240, health: 600 });33  });3435  it('GET /auth/status has its own per-IP bucket, untouched by (and not touching) the credential bucket', async () => {36    const ip = '203.0.113.10';37    // The login budget of this client is exhausted...38    expect(await statuses(7, () => postJson('/auth/passkey/login/options', ip))).toEqual([200, 200, 200, 200, 200, 429, 429]);39    // ...page loads still work, up to their own (much larger) limit.40    const page = await statuses(14, () => get('/auth/status', ip));41    expect(page).toEqual([...Array<number>(12).fill(200), 429, 429]);42    const refused = await get('/auth/status', ip);43    expect(refused.json().error.code).toBe('rate_limited');44    expect(refused.headers['retry-after']).toBeDefined();45    // Another client: its status calls did not consume its login budget either.46    const other = '203.0.113.11';47    expect(await statuses(12, () => get('/auth/status', other))).toEqual(Array<number>(12).fill(200));48    expect(await statuses(5, () => postJson('/auth/passkey/login/options', other))).toEqual(Array<number>(5).fill(200));49  });5051  it('GET /health is throttled separately: neither logins nor page loads can make health checks fail', async () => {52    const ip = '203.0.113.10'; // both other buckets of this client are exhausted (previous test)53    expect((await get('/auth/status', ip)).statusCode).toBe(429);54    expect((await postJson('/auth/login', ip)).statusCode).toBe(429);55    expect(await statuses(40, () => get('/health', ip))).toEqual(Array<number>(40).fill(200));56    expect((await get('/health', ip)).statusCode).toBe(429); // still bounded57    expect((await get('/health', '203.0.113.12')).statusCode).toBe(200);58  });5960  it('an authenticated caller is limited per session by the API limit, wherever it comes from', async () => {61    const cookie = admin.cookieHeader;62    // More than the anonymous status limit, from an address whose anonymous bucket is exhausted.63    const calls = await statuses(20, () => get('/auth/status', '203.0.113.10', cookie));64    expect(calls).toEqual(Array<number>(20).fill(200));65    expect((await get('/auth/status', '203.0.113.10', cookie)).json()).toMatchObject({ authenticated: true });66    // They were counted in the session's API bucket (30/min here, a few already used while the67    // administrator was enrolled): at most 9 calls are left, shared with every other route.68    const rest = await statuses(11, () => get('/settings', '203.0.113.30', cookie));69    const ok = rest.filter((c) => c === 200).length;70    expect(ok).toBeGreaterThan(0);71    expect(ok).toBeLessThanOrEqual(9);72    expect(rest).toEqual([...Array<number>(ok).fill(200), ...Array<number>(11 - ok).fill(429)]);73    expect((await get('/auth/status', '203.0.113.31', cookie)).statusCode).toBe(429);74  });7576  it('the TOTP step of a half-authenticated session is on the strict per-IP budget too', async () => {77    const half = new Client(h); // injects from 127.0.0.178    expect((await half.post('/auth/login', { username: 'admin', password: PASSWORD })).json()).toMatchObject({ next: 'totp' });79    await half.sync();80    let refused = 0;81    for (let i = 0; i < 6 && refused === 0; i++) if ((await half.post('/auth/passkey/login/options')).statusCode === 429) refused++;82    expect(refused).toBe(1); // 5/min for this address, the login above included83    const failures = async (): Promise<number> => (await h.server.ctx.db.q<{ n: number }>('SELECT count(*)::int AS n FROM login_attempts WHERE NOT ok'))[0]?.n ?? -1;84    const before = await failures();85    const res = await half.post('/auth/totp', { code: '000000' });86    expect([res.statusCode, res.json().error.code]).toEqual([429, 'rate_limited']);87    expect(await failures()).toBe(before); // refused before the code was looked at88  });8990  it('a made-up cookie does not buy a fresh bucket', async () => {91    const ip = '203.0.113.40';92    const codes = await statuses(14, () => get('/auth/status', ip, `aos_sid=${'x'.repeat(30)}${Math.random().toString(36).slice(2).padEnd(13, 'y')}`));93    expect(codes).toEqual([...Array<number>(12).fill(200), 429, 429]);94  });95});96
Files main
Menu

Theme