diff --git a/apps/kernel/src/http.ts b/apps/kernel/src/http.ts index e82f7e0..1fbda7e 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 3316aa3..1c0a89e 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 20e3f8f..7f67fd8 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. | +| `/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. | | `/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 0000000..775cb9e --- /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 381dda3..f8aaec9 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 0000000..9dd225e --- /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); + }); + } +});