/** * TASK-0134 — rate-limit verification (spec: "Rate limits incl. the two new * buckets"; ASVS L2 §5.4 "Denial of Service"). * * THE TWO NEW BUCKETS (Phase 15) are LIVE-PROVEN here — each is 429-tested * against the running API: * * - **Incoming webhook — 60/min per webhookId** (`WEBHOOK_RATE_PER_MINUTE` * in `@factory/shared/automation.ts`, Redis `auto:webhook-rate:` * INCR+EXPIRE window). The limit fires BEFORE signature verification, so * the test trips it with unsigned JSON bodies: 60×401 then 429 * `WEBHOOK_RATE_LIMITED` + `retryAfterSeconds: 60` on the 61st. * - **Public form — 5/min per IP per form** (`FORM_SUBMIT_LIMIT` in * `apps/api/modules/forms/rate-limit.ts`, Redis sliding window * `form:rl::`, 60 s). The test creates a throwaway workspace → * database page → PUBLISHED form (one SHORT_TEXT question) and submits * until 429 `RATE_LIMITED` — the 6th is rejected, the first 5 are 201. * (The limit is STRICTER than the spec's 20/min example — a fix must * never weaken a control, so the stricter value is KEPT and the delta * documented in docs/security/audit-report.md.) * * The EXISTING global buckets are env-configured (`RATE_LIMIT_API` default * 100/min, `RATE_LIMIT_AUTH` default 10/min, `RATE_LIMIT_API_KEY` default * 600/min — per-user then per-IP fallback, headers X-RateLimit-*, fail-open * when Redis is down, `/internal/*` exempt). Their DEFAULTS are asserted * statically (the API's env schema), and their LIVE 429 behavior is covered * by the api e2e rate-limit suite (which lowers the limits via env); * tripping them here would require dev .env values of 100000/10000, which * this suite deliberately does not touch (never weaken/raise a control * mid-test). The spec's example values (60/min user, 20/min forms) differ * from the shipped defaults — recorded delta, not a fix. * * Live tests skip (not fail) when the API is down; SEC_REQUIRE_LIVE=1 (CI) * makes a down API fail the suite. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { WEBHOOK_RATE_PER_MINUTE, webhookRateKey } from "@factory/shared"; import { checkLiveApp, apiFetch } from "./helpers/app.js"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const live = await checkLiveApp(); const itLive = it.skipIf(!live); const PASSWORD = "Str0ngPass!x9"; async function j(res: Response): Promise> { try { return (await res.json()) as Record; } catch { return {}; } } function asErr(body: Record): { code?: string; details?: { retryAfterSeconds?: number; reason?: string }[] } { return (body.error ?? {}) as { code?: string; details?: { retryAfterSeconds?: number; reason?: string }[] }; } // --------------------------------------------------------------------------- // 1. The two new buckets — constants + key shapes (static) // --------------------------------------------------------------------------- describe("rate-limits / the two new buckets (constants + key shapes)", () => { it("incoming-webhook bucket: 60/min, keyed per webhookId", () => { // The spec's "two new buckets" — the webhook one must be exactly 60/min // and MUST be keyed per webhookId (one abusive webhook cannot starve // another, and a burst on one id cannot 429 a different id). expect(WEBHOOK_RATE_PER_MINUTE).toBe(60); const k1 = webhookRateKey("id-one"); const k2 = webhookRateKey("id-two"); expect(k1).not.toBe(k2); expect(k1).toContain("id-one"); expect(k1).toMatch(/^auto:webhook-rate:/); }); it("public-form bucket: 5/min per IP per form (stricter than the spec's 20 — kept)", () => { // The form limiter is HARD-CODED at 5/min (FORM_SUBMIT_LIMIT) — stricter // than the spec's 20/min example. A security fix must never WEAKEN a // control, so the stricter value is kept and the delta is recorded // (audit-report.md). Pinned against the source (no app-code import): const src = readFileSync(REPO_ROOT + "/apps/api/src/modules/forms/rate-limit.ts", "utf8"); expect(src).toMatch(/export const FORM_SUBMIT_LIMIT = 5/); expect(src).toMatch(/export const FORM_RL_WINDOW_MS = 60_000/); // Keyed per slug AND per ip (an abusive IP on one form cannot starve // another form or another IP): expect(src).toMatch(/`form:rl:\$\{slug\}:\$\{ip\}`/); }); }); // --------------------------------------------------------------------------- // 2. Existing global buckets — env defaults (static; live 429 covered by the // api e2e suite that lowers the limits) // --------------------------------------------------------------------------- describe("rate-limits / global buckets (env defaults, documented delta vs spec)", () => { it("the API env schema ships the canonical defaults (100/10/600)", () => { const envSrc = readFileSync(REPO_ROOT + "/apps/api/src/config/env.ts", "utf8"); expect(envSrc).toMatch(/RATE_LIMIT_API:.*default\(100\)/); expect(envSrc).toMatch(/RATE_LIMIT_AUTH:.*default\(10\)/); expect(envSrc).toMatch(/RATE_LIMIT_API_KEY:.*default\(600\)/); }); }); // --------------------------------------------------------------------------- // 3. Live: incoming-webhook 60/min → 429 on the 61st // --------------------------------------------------------------------------- describe("rate-limits / live incoming-webhook 60/min", () => { itLive( "61st unsigned request to a fresh active webhook is 429 WEBHOOK_RATE_LIMITED (the first 60 are not 429)", async () => { // Setup: throwaway user → personal workspace → automation with a // WEBHOOK trigger (the server generates webhookId + secret) → ACTIVE. const email = `secrlw_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`; const reg = await apiFetch("/auth/register", { method: "POST", body: JSON.stringify({ name: "RL", email, password: PASSWORD }) }); if (reg.status !== 201) throw new Error(`register ${reg.status}: ${await reg.text()}`); const login = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password: PASSWORD }) }); const lb = (await login.json()) as { accessToken: string }; const H = { authorization: `Bearer ${lb.accessToken}` }; const wl = await apiFetch("/workspaces", { headers: H }); const wlb = (await wl.json()) as { data: { id: string }[] }; const wid = wlb.data[0]!.id; const au = await apiFetch(`/workspaces/${wid}/automations`, { method: "POST", headers: H, body: JSON.stringify({ name: "RL Probe", triggers: [{ type: "WEBHOOK", config: {} }] }) }); const auText = await au.text(); expect(au.status, `automation create ${auText}`).toBeLessThan(300); const aub = JSON.parse(auText) as { id: string; triggers: { type: string; config: { webhookId: string } }[] }; const webhookId = aub.triggers.find((t) => t.type === "WEBHOOK")!.config.webhookId; expect(webhookId).toMatch(/^[a-f0-9]{24}$/); const act = await apiFetch(`/automations/${aub.id}/status`, { method: "POST", headers: H, body: JSON.stringify({ status: "ACTIVE" }) }); expect(act.status, "activate automation").toBe(200); // Fire 61 requests. Each carries a DISTINCT timestamp+nonce pair is // irrelevant: with NO signature the endpoint 401s AFTER the rate // increment (the increment is step 3, verification step 4) — so every // request charges the bucket, and the 61st must be 429. const base = `${process.env.SEC_API_URL ?? "http://localhost:4000"}/api/v1/webhooks/incoming/${webhookId}`; let seen429 = 0; let first429At = -1; let code = ""; let lastBody: Record = {}; for (let i = 1; i <= 61; i++) { const res = await fetch(base, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ i, nonce: Math.random() }), signal: AbortSignal.timeout(15_000) }); code = String(res.status); if (res.status === 429) { seen429++; if (first429At === -1) { first429At = i; lastBody = (await res.json()) as Record; } else { await res.text(); } } else { await res.text(); // The rate-limit fires before signature: an unsigned request that // is under the limit must be 401 (bad signature), never 202. expect([400, 401, 404, 413], `request ${i}: under-limit must be a signature/validation 4xx`).toContain(res.status); } } expect(seen429, "the 61st request must be 429").toBeGreaterThanOrEqual(1); expect(first429At, "the 429 starts at the 61st (60 allowed)").toBe(61); const err = asErr(lastBody); expect(err.code, "canonical 429 code").toBe("WEBHOOK_RATE_LIMITED"); expect(err.details?.[0]?.retryAfterSeconds).toBe(60); void code; }, { timeout: 300_000 } ); }); // --------------------------------------------------------------------------- // 4. Live: public-form 5/min per IP per form → 429 on the 6th // --------------------------------------------------------------------------- describe("rate-limits / live public-form 5/min per IP per form", () => { itLive( "the 6th submission to a fresh published form is 429 RATE_LIMITED (the first 5 are 201) + Retry-After", async () => { // Setup: user → personal ws → database page → form w/ one SHORT_TEXT // question → PUBLISHED. (The rate limiter charges any non-honeypot // submission BEFORE answer validation, so `{"answers":{}}` posts count.) const email = `secrlf_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`; const reg = await apiFetch("/auth/register", { method: "POST", body: JSON.stringify({ name: "RL", email, password: PASSWORD }) }); if (reg.status !== 201) throw new Error(`register ${reg.status}: ${await reg.text()}`); const login = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password: PASSWORD }) }); const lb = (await login.json()) as { accessToken: string }; const H = { authorization: `Bearer ${lb.accessToken}` }; const wl = await apiFetch("/workspaces", { headers: H }); const wid = ((await wl.json()) as { data: { id: string }[] }).data[0]!.id; const page = await apiFetch(`/workspaces/${wid}/pages`, { method: "POST", headers: H, body: JSON.stringify({ title: "RL DB", type: "database" }) }); const pageText = await page.text(); expect(page.status, `database page create ${pageText}`).toBeLessThan(300); const pageB = JSON.parse(pageText) as { id: string; databaseId: string }; const form = await apiFetch(`/databases/${pageB.databaseId}/forms`, { method: "POST", headers: H, body: JSON.stringify({ name: "RL Form", questions: [{ type: "SHORT_TEXT", label: "Contact", propertyId: null }] }) }); const formText = await form.text(); expect(form.status, `form create ${formText}`).toBeLessThan(300); const formB = JSON.parse(formText) as { id: string }; const pub = await apiFetch(`/forms/${formB.id}`, { method: "PATCH", headers: H, body: JSON.stringify({ status: "PUBLISHED" }) }); const pubText = await pub.text(); expect(pub.status, `publish ${pubText}`).toBeLessThan(300); const slug = (JSON.parse(pubText) as { slug: string }).slug; expect(slug).toMatch(/^[a-z0-9-]+$/); // Fire 6 submissions (same IP, same form): 5×201 then 429. let got429At = -1; let retryAfterHeader: string | null = null; let retryAfterSeconds: number | undefined; for (let i = 1; i <= 6; i++) { const res = await fetch( `${process.env.SEC_API_URL ?? "http://localhost:4000"}/api/v1/public/forms/${slug}/submit`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ answers: {} }), signal: AbortSignal.timeout(15_000) } ); if (res.status === 201) { await res.text(); } else if (res.status === 429) { got429At = i; retryAfterHeader = res.headers.get("retry-after"); const body = (await res.json()) as Record; retryAfterSeconds = asErr(body).details?.[0]?.retryAfterSeconds; expect(asErr(body).code).toBe("RATE_LIMITED"); } else { const body = await res.text(); throw new Error(`submission ${i}: expected 201/429, got ${res.status}: ${body}`); } } expect(got429At, "the 6th submission must be 429 (5/min)").toBe(6); expect(retryAfterHeader, "the 429 carries Retry-After").toBe("60"); expect(retryAfterSeconds, "the 429 body carries retryAfterSeconds").toBe(60); }, { timeout: 300_000 } ); });