From 83417c3f0ac478df9db81ef0a231b543842675c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:28:10 +0000 Subject: [PATCH] test(auth): verify Supabase Auth rate limits through the /auth/v1 proxy (#34) A Playwright spec on the compose stack drives password sign-in, sign-up and recovery through /auth/v1 until Supabase Auth answers 429 over_request_rate_limit. Each allows a burst of 30 requests per client address; client-supplied forwarding headers do not reach a fresh bucket, and another client address (inside the kernel container) is unaffected. The spec restarts the auth service before and after, since the limits live in its memory and every spec shares the runner's address. The proxy now also drops Sb-Forwarded-For, which Supabase Auth uses as the rate-limit key ahead of X-Real-IP when GOTRUE_SECURITY_SB_FORWARDED_FOR_ENABLED is on. The email budget turned out to be one bucket per deployment; that is Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013LrvuThKGaPX1uDwUiwmGQ --- apps/kernel/src/http.ts | 13 +++- apps/kernel/test/http.test.ts | 3 +- docs/plan/crove-os-kernel.md | 2 +- e2e/lib/compose.ts | 15 +++++ e2e/lib/db.ts | 12 +--- e2e/tests/auth-rate-limits.spec.ts | 96 ++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 e2e/lib/compose.ts create mode 100644 e2e/tests/auth-rate-limits.spec.ts diff --git a/apps/kernel/src/http.ts b/apps/kernel/src/http.ts index e82f7e0c..1fbda7e2 100644 --- a/apps/kernel/src/http.ts +++ b/apps/kernel/src/http.ts @@ -99,8 +99,17 @@ function forwardable(source: Headers, extraDrop: ReadonlySet = new Set() }); return out; } -// Client-supplied forwarding headers are dropped; the proxy sets X-Real-IP from the socket instead. -const SPOOFABLE = new Set(['forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'x-client-ip']); +// Client-supplied forwarding headers are dropped; the proxy sets X-Real-IP from the socket instead. Supabase Auth keys +// its rate limits on X-Real-IP, or on Sb-Forwarded-For when GOTRUE_SECURITY_SB_FORWARDED_FOR_ENABLED is on (#34). +const SPOOFABLE = new Set([ + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', + 'x-client-ip', + 'sb-forwarded-for', +]); // Public Supabase Auth endpoints the web app needs. Everything else (notably /admin) is not reachable. const AUTH_PUBLIC_PATHS = /^\/(?:token|signup|verify|recover|user|logout|otp|resend|settings|health|authorize|callback|reauthenticate|factors(?:\/[\w-]+(?:\/(?:challenge|verify))?)?|\.well-known\/jwks\.json)$/; diff --git a/apps/kernel/test/http.test.ts b/apps/kernel/test/http.test.ts index 3316aa3d..1c0a89e3 100644 --- a/apps/kernel/test/http.test.ts +++ b/apps/kernel/test/http.test.ts @@ -133,7 +133,7 @@ describe('auth proxy', () => { it('forwards public endpoints with the client address from the socket, not from headers', async () => { const request = new Request('http://localhost/auth/v1/token?grant_type=password', { method: 'POST', - headers: { 'content-type': 'application/json', 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' }, + headers: { 'content-type': 'application/json', 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4', 'sb-forwarded-for': '1.2.3.4' }, body: '{}', }); const response = await app.fetch(request, { incoming: { socket: { remoteAddress: '203.0.113.9' } } }); @@ -141,6 +141,7 @@ describe('auth proxy', () => { expect(seen?.path).toBe('/token?grant_type=password'); expect(seen?.headers['x-real-ip']).toBe('203.0.113.9'); expect(seen?.headers['x-forwarded-for']).toBeUndefined(); + expect(seen?.headers['sb-forwarded-for']).toBeUndefined(); }); }); diff --git a/docs/plan/crove-os-kernel.md b/docs/plan/crove-os-kernel.md index 20e3f8f7..7f67fd86 100644 --- a/docs/plan/crove-os-kernel.md +++ b/docs/plan/crove-os-kernel.md @@ -902,7 +902,7 @@ wake: LISTEN crove_outbox (per-statement NOTIFY) + poll every 500 ms |---|---|---| | `/` | MVP | SPA static files (`apps/app/dist`); deep links fall back to `index.html`; hash-based CSP + Trusted Types (spike A14) | | `/api/config` | MVP | Public runtime config: Supabase Auth URL and public key, feature flags; no secrets | -| `/auth/v1/*` | MVP, self-host | Allowlisted proxy to the bundled Supabase Auth; body-limited; passes the client IP for Supabase Auth's rate limits. Absent when the deployment points at an external Supabase Auth (Crove Cloud), whose email limit is a setting of that project.
  • **Email budget (#190).** Supabase Auth sends every email from one deployment-wide bucket (`GOTRUE_RATE_LIMIT_EMAIL_SENT`, set from `AUTH_RATE_LIMIT_EMAIL_SENT`, default 100 per hour in compose; counted while confirmations are on), and its per-address limits (`GOTRUE_RATE_LIMIT_OTP`, a burst of 30) are larger than that bucket, so one client could use it up and stop confirmation and recovery emails for everyone for an hour. Before forwarding a request that sends an email (sign-up while confirmations are on, recovery, OTP and magic link, resend, email change, reauthentication) the proxy reserves a slot in the database: at most `AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` (default 5) per client address in any rolling hour, IPv6 per /64, and one per recipient every `AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS` (default 60). Over either: 429 `over_email_send_rate_limit` (Supabase Auth's own shape) with `Retry-After`, nothing forwarded. A request Supabase Auth refuses (4xx, 5xx) sent nothing and gives its slot back; one it does not answer in time keeps it, since it may still have sent. The body is read as Supabase Auth's Go decoder reads it (keys in any case, any Content-Type); a body the proxy cannot read with certainty still counts per client, with no recipient.
  • The client address is the socket address, or with `TRUSTED_PROXY_HOPS` the `X-Forwarded-For` entry the trusted proxy appended; client-supplied forwarding headers are dropped. The count runs in `crove_kernel.auth_email_reserve()` (executable by `crove_app` alone) under per-client and per-recipient advisory locks with a 3 s lock timeout, so it holds across API instances and restarts, and of concurrent requests at the limit exactly as many as the limit win. The table stores SHA-256 hashes only and each reservation sweeps rows older than an hour, so it holds about one hour of traffic; Supabase Auth itself writes a row per sign-up, so the limiter adds no write an outsider could not cause already. If the limiter cannot decide (database error), the proxy answers 503: the budget never opens.
  • A public deployment refuses to start when fewer than 10 client addresses could use up the budget (`AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` x 10 > `AUTH_RATE_LIMIT_EMAIL_SENT`). Behind a reverse proxy with `TRUSTED_PROXY_HOPS=0` every client shares the proxy's address and one budget; the proxy logs a warning once when requests carry `X-Forwarded-For`.
  • Decision (2026-10-16): size the budget to the SMTP provider's quota and cap it per client; no CAPTCHA by default, since the OSS core must run with no third-party service. Residual risks: a client with many addresses (a botnet, many IPv6 /64s) can still use up the budget, and optional CAPTCHA on sign-up and recovery is #203; the per-recipient interval lets someone with several addresses delay one person's recovery email by a minute at a time, as Supabase Auth's own per-user frequency (`GOTRUE_SMTP_MAX_FREQUENCY`, 60 s) already does; several users behind one IPv4 address (an office NAT) share its 5 emails an hour, so such deployments raise `AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` with the budget. Tests: `apps/kernel/test/auth-email-limit.test.ts` (which requests count, client buckets, the configuration guard, the proxy's answers), `apps/kernel/test/invariant/auth-email-limit.db.test.ts` (EXECUTE matrix, closed table, per-client and per-recipient rules, concurrency, sweep) and `apps/kernel/test/auth-email-proxy.db.test.ts` (through the proxy in front of a fake Supabase Auth with one deployment-wide bucket: one client signing up 30 new addresses gets 5 through and another client still gets its confirmation and recovery emails; without the cap the same flood blocks it).
| +| `/auth/v1/*` | MVP, self-host | Allowlisted proxy to the bundled Supabase Auth; body-limited; drops the client's forwarding headers (`Sb-Forwarded-For` included) and sets `X-Real-IP` from the socket, or from the trusted proxies' `X-Forwarded-For` entry, for Supabase Auth's per-client rate limits. Verified end to end by `e2e/tests/auth-rate-limits.spec.ts` (#34, with the email budget below raised on the e2e stack): sign-in, sign-up and recovery each allow a burst of 30 requests per client address in Supabase Auth, then 429 `over_request_rate_limit` (refill: sign-in and token refresh 150, sign-up and recovery 30 per 5 minutes); spoofed headers do not reach a fresh bucket and another address is unaffected. Supabase Auth's port is not published, so the proxy is the only way in. Absent when the deployment points at an external Supabase Auth (Crove Cloud), whose email limit is a setting of that project.
  • **Email budget (#190).** Supabase Auth sends every email from one deployment-wide bucket (`GOTRUE_RATE_LIMIT_EMAIL_SENT`, set from `AUTH_RATE_LIMIT_EMAIL_SENT`, default 100 per hour in compose; counted while confirmations are on), and its per-address limits (`GOTRUE_RATE_LIMIT_OTP`, a burst of 30) are larger than that bucket, so one client could use it up and stop confirmation and recovery emails for everyone for an hour. Before forwarding a request that sends an email (sign-up while confirmations are on, recovery, OTP and magic link, resend, email change, reauthentication) the proxy reserves a slot in the database: at most `AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` (default 5) per client address in any rolling hour, IPv6 per /64, and one per recipient every `AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS` (default 60). Over either: 429 `over_email_send_rate_limit` (Supabase Auth's own shape) with `Retry-After`, nothing forwarded. A request Supabase Auth refuses (4xx, 5xx) sent nothing and gives its slot back; one it does not answer in time keeps it, since it may still have sent. The body is read as Supabase Auth's Go decoder reads it (keys in any case, any Content-Type); a body the proxy cannot read with certainty still counts per client, with no recipient.
  • The client address is the socket address, or with `TRUSTED_PROXY_HOPS` the `X-Forwarded-For` entry the trusted proxy appended; client-supplied forwarding headers are dropped. The count runs in `crove_kernel.auth_email_reserve()` (executable by `crove_app` alone) under per-client and per-recipient advisory locks with a 3 s lock timeout, so it holds across API instances and restarts, and of concurrent requests at the limit exactly as many as the limit win. The table stores SHA-256 hashes only and each reservation sweeps rows older than an hour, so it holds about one hour of traffic; Supabase Auth itself writes a row per sign-up, so the limiter adds no write an outsider could not cause already. If the limiter cannot decide (database error), the proxy answers 503: the budget never opens.
  • A public deployment refuses to start when fewer than 10 client addresses could use up the budget (`AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` x 10 > `AUTH_RATE_LIMIT_EMAIL_SENT`). Behind a reverse proxy with `TRUSTED_PROXY_HOPS=0` every client shares the proxy's address and one budget; the proxy logs a warning once when requests carry `X-Forwarded-For`.
  • Decision (2026-10-16): size the budget to the SMTP provider's quota and cap it per client; no CAPTCHA by default, since the OSS core must run with no third-party service. Residual risks: a client with many addresses (a botnet, many IPv6 /64s) can still use up the budget, and optional CAPTCHA on sign-up and recovery is #203; the per-recipient interval lets someone with several addresses delay one person's recovery email by a minute at a time, as Supabase Auth's own per-user frequency (`GOTRUE_SMTP_MAX_FREQUENCY`, 60 s) already does; several users behind one IPv4 address (an office NAT) share its 5 emails an hour, so such deployments raise `AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR` with the budget. Tests: `apps/kernel/test/auth-email-limit.test.ts` (which requests count, client buckets, the configuration guard, the proxy's answers), `apps/kernel/test/invariant/auth-email-limit.db.test.ts` (EXECUTE matrix, closed table, per-client and per-recipient rules, concurrency, sweep) and `apps/kernel/test/auth-email-proxy.db.test.ts` (through the proxy in front of a fake Supabase Auth with one deployment-wide bucket: one client signing up 30 new addresses gets 5 through and another client still gets its confirmation and recovery emails; without the cap the same flood blocks it).
| | `/api/rpc/*` | MVP | oRPC; Bearer Supabase access token + `X-Crove-Org` (org slug); every command through `execute()` | | `/api/files`, `/api/files/:id`, `/api/export/journal.xlsx` | MVP | Upload (type sniffed from content, size-limited), download, Excel export; same token and org header | | `/api/inbound/email` | MVP | Raw MIME from a mail worker, authenticated with `INBOUND_SECRET`; the recipient token selects the org. Decided: the API only verifies, size-limits and stores the raw message; parsing and `kernel.inbound.receive` run in the worker (External-tenant gate T5) | diff --git a/e2e/lib/compose.ts b/e2e/lib/compose.ts new file mode 100644 index 00000000..775cb9e5 --- /dev/null +++ b/e2e/lib/compose.ts @@ -0,0 +1,15 @@ +// Runs `docker compose` against the e2e stack with the same project and files as `stack:up`. +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const FILES = ['../../docker-compose.yml', '../compose.e2e.yml'].map((f) => fileURLToPath(new URL(f, import.meta.url))); +const PROJECT = process.env.E2E_COMPOSE_PROJECT ?? 'crove-e2e'; + +// Returns stdout; stderr goes to the test output. +export function compose(args: string[], input?: string): string { + return execFileSync('docker', ['compose', '-p', PROJECT, ...FILES.flatMap((f) => ['-f', f]), ...args], { + input, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'inherit'], + }); +} diff --git a/e2e/lib/db.ts b/e2e/lib/db.ts index 381dda35..f8aaec90 100644 --- a/e2e/lib/db.ts +++ b/e2e/lib/db.ts @@ -1,17 +1,11 @@ // Reads the stack's database as the PostgreSQL superuser (no RLS), so assertions see exactly what was stored. // Runs psql inside the compose `db` service; the database port is never published. -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const COMPOSE_FILES = ['../../docker-compose.yml', '../compose.e2e.yml'].map((f) => fileURLToPath(new URL(f, import.meta.url))); -const PROJECT = process.env.E2E_COMPOSE_PROJECT ?? 'crove-e2e'; +import { compose } from './compose.ts'; // `query` uses psql variables (:'name') for every value in `vars`. Returns the rows as JSON objects. export function sql(query: string, vars: Record = {}): T[] { - const args = ['compose', '-p', PROJECT, ...COMPOSE_FILES.flatMap((f) => ['-f', f]), 'exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A']; - args.push('-v', 'ON_ERROR_STOP=1'); + const args = ['exec', '-T', 'db', 'psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-t', '-A', '-v', 'ON_ERROR_STOP=1']; for (const [name, value] of Object.entries(vars)) args.push('-v', `${name}=${value}`); const script = `SELECT coalesce(json_agg(row_to_json(q)), '[]'::json) FROM (${query}) q;\n`; - const out = execFileSync('docker', args, { input: script, encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] }); - return JSON.parse(out.trim()) as T[]; + return JSON.parse(compose(args, script).trim()) as T[]; } diff --git a/e2e/tests/auth-rate-limits.spec.ts b/e2e/tests/auth-rate-limits.spec.ts new file mode 100644 index 00000000..9dd225e2 --- /dev/null +++ b/e2e/tests/auth-rate-limits.spec.ts @@ -0,0 +1,96 @@ +// Supabase Auth's rate limits through the kernel's /auth/v1 proxy (issue #34). Supabase Auth keys its per-client +// limits on X-Real-IP (GOTRUE_RATE_LIMIT_HEADER), which the proxy sets from the client socket after dropping every +// client-supplied forwarding header. Sign-in, sign-up and recovery each allow a burst of 30 requests per client +// address; after that Supabase Auth answers 429 until the bucket refills (sign-in 150, sign-up and recovery 30 per +// 5 minutes). The limits count attempts, so wrong passwords and unknown addresses use them up too. In front of these, +// the proxy's own email budget (#190) allows 5 sign-ups and recoveries per client address an hour; compose.e2e.yml +// raises it so the test reaches Supabase Auth's limits (the proxy's budget has its own tests in apps/kernel). +// The limits live in Supabase Auth's memory and this runner is one client address for every spec, so the auth +// service is restarted before (a fresh bucket) and after (the other specs and the next run start from fresh limits). +import { type APIRequestContext, type APIResponse, type PlaywrightWorkerArgs, expect, test } from '@playwright/test'; +import { compose } from '../lib/compose.ts'; +import { PASSWORD, newEmail } from '../lib/session.ts'; + +const BURST = 30; +// Headers a client could send to pass as another address. None of them may reach a fresh bucket. +const SPOOFED = { + 'x-forwarded-for': '198.51.100.7', + 'x-real-ip': '198.51.100.7', + 'x-client-ip': '198.51.100.7', + forwarded: 'for=198.51.100.7', + 'sb-forwarded-for': '198.51.100.7', +}; + +interface Endpoint { + name: string; + path: string; + body: () => Record; + // The answer while the client is under the limit. + status: number; +} + +const ENDPOINTS: Endpoint[] = [ + { + name: 'password sign-in', + path: '/auth/v1/token?grant_type=password', + body: () => ({ email: newEmail('rate-signin'), password: 'e2e-wrong-password' }), + status: 400, + }, + { name: 'sign-up', path: '/auth/v1/signup', body: () => ({ email: newEmail('rate-signup'), password: PASSWORD }), status: 200 }, + { name: 'password recovery', path: '/auth/v1/recover', body: () => ({ email: newEmail('rate-recover') }), status: 200 }, +]; + +// Restarting Supabase Auth clears its in-memory limits; waits until it answers through the proxy again. +async function restartAuth(playwright: PlaywrightWorkerArgs['playwright'], baseURL = 'http://localhost:8080'): Promise { + compose(['restart', 'auth']); + const api = await playwright.request.newContext({ baseURL }); + try { + await expect.poll(async () => (await api.get('/auth/v1/health').catch(() => null))?.status(), { timeout: 60_000 }).toBe(200); + } finally { + await api.dispose(); + } +} + +function post(request: APIRequestContext, endpoint: Endpoint, headers: Record = {}): Promise { + return request.post(endpoint.path, { data: endpoint.body(), headers }); +} + +// The same request from another client address: Node inside the kernel container, which reaches the proxy over +// 127.0.0.1 instead of the address this runner comes from. +function statusFromAnotherClient(endpoint: Endpoint): number { + const script = + "fetch('http://127.0.0.1:8080' + process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: process.argv[2] })" + + '.then((r) => console.log(r.status))'; + return Number(compose(['exec', '-T', 'crove', 'node', '-e', script, endpoint.path, JSON.stringify(endpoint.body())]).trim()); +} + +test.describe('Supabase Auth rate limits through /auth/v1', () => { + test.beforeAll(async ({ playwright }, testInfo) => { + await restartAuth(playwright, testInfo.project.use.baseURL); + }); + + test.afterAll(async ({ playwright }, testInfo) => { + await restartAuth(playwright, testInfo.project.use.baseURL); + }); + + for (const endpoint of ENDPOINTS) { + test(`${endpoint.name}: ${BURST} requests per client address, then 429 that spoofed headers do not lift`, async ({ request }) => { + const statuses: number[] = []; + let refused: APIResponse | undefined; + // Room past the burst: the bucket refills slowly while the loop runs (sign-up: one request per 10 s). + for (let i = 0; i < BURST + 10 && !refused; i += 1) { + const response = await post(request, endpoint); + statuses.push(response.status()); + if (response.status() === 429) refused = response; + } + expect(statuses.slice(0, BURST)).toEqual(Array(BURST).fill(endpoint.status)); + expect(refused, `no 429 after ${statuses.length} requests: ${statuses.join(' ')}`).toBeDefined(); + expect(await refused?.json()).toMatchObject({ error_code: 'over_request_rate_limit' }); + + // The proxy drops the client's forwarding headers, so they do not reach another bucket. + expect((await post(request, endpoint, SPOOFED)).status()).toBe(429); + // The limit is per client address, not one bucket for everyone. + expect(statusFromAnotherClient(endpoint)).toBe(endpoint.status); + }); + } +});