Repository navigation
test(auth): verify Supabase Auth rate limits through the /auth/v1 proxy #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JOY (JOY)
wants to merge
1
commit into
feat/spa-auth-flows
Choose a base branch
from
test/auth-rate-limits
base: feat/spa-auth-flows
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'], | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>(query: string, vars: Record<string, string> = {}): 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[]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string>; | ||
| // 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<void> { | ||
| 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<string, string> = {}): Promise<APIResponse> { | ||
| 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<number>(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); | ||
| }); | ||
| } | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.