From 6af610d9cec74cd2b0f4c46d3e8ee753a9a32b8d Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 19 Sep 2026 10:00:36 -0400 Subject: [PATCH] fix: isolate anonymous rollout rate limits --- README.md | 2 +- docs/restore-rehearsal.md | 23 ++++++++--------------- scripts/release-gate-smoke.ts | 27 +++++++++++++++++++++++++-- src/routes/api/chat/+server.ts | 2 +- src/routes/api/sync/+server.ts | 2 +- tests/workflow-config.test.ts | 4 ++-- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a0080fc..0aeadc6 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ docker compose --profile admin up -d dashboard Invoking the `tools` worker explicitly imports **production archives** into `/app/data/reference`, the shared web/worker volume. `--check` fetches metadata only. Fixture imports require an explicit `--fixture` override and a separate demo output directory/volume; never use a fixture worker against the live volume. -For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend defaults to the immutable digest exercised by this repository’s smoke test. The optional dashboard’s `latest` default is a development convenience: pin a tested digest before production. See [security gates](docs/security.md). +For remote exposure use HTTPS reverse proxies for the app and Convex browser WebSocket/API endpoints. `PUBLIC_CONVEX_URL` must be browser-reachable, while Compose's `CONVEX_URL=http://backend:3210` is internal. Configure exact origins/callbacks. Keep backend/admin ports on loopback/management networks. The backend and optional dashboard defaults are immutable digest-pinned images in `compose.yaml`; use only reviewed digest overrides for production. See [security gates](docs/security.md). ## Structure diff --git a/docs/restore-rehearsal.md b/docs/restore-rehearsal.md index bb14d62..686a83b 100644 --- a/docs/restore-rehearsal.md +++ b/docs/restore-rehearsal.md @@ -132,17 +132,11 @@ allocated loopback ports. Do not use a production callback or provider key. configuration against the restored instance using this same in-memory key is the credential continuity check. -3. Run the existing synthetic write smoke against this backend, with - `SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It - exercises login/session, staged publication, failed publication preserving - the previous generation, tenant isolation, JWT audience checks, and local - revocation: - - ```sh - SMOKE_ALLOW_WRITE=1 CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ - bun "$repo/scripts/smoke-convex.ts" | tee "$work/convex-smoke.log" - ``` - +3. The legacy `scripts/smoke-convex.ts` exercises useful synthetic writes, + but currently accepts an arbitrary `CONVEX_SELF_HOSTED_URL` when write is + enabled. Do **not** execute it as part of this runbook; it remains + unexecuted evidence until it is wrapped with the same loopback target, + explicit disposable confirmation, and key scoping as the marker tool. Then write a deterministic marker that can be looked up after restore. Use a fresh marker and synthetic character ID for every rehearsal; do not use a random-only smoke result as proof of record survival: @@ -217,8 +211,9 @@ allocated loopback ports. Do not use a production callback or provider key. bun "$repo/scripts/configure-convex.ts" >/dev/null ``` -3. Verify the deterministic marker before running any fresh smoke, then run - the synthetic Convex smoke with fresh IDs: +3. Verify the deterministic marker before any other restore evidence. The + legacy synthetic Convex smoke remains unexecuted for the safety reason + above; do not substitute its arbitrary-target behavior for restore proof: ```sh RESTORE_MARKER="$RESTORE_MARKER" \ @@ -226,8 +221,6 @@ allocated loopback ports. Do not use a production callback or provider key. RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \ CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts" - SMOKE_ALLOW_WRITE=1 CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ - bun "$repo/scripts/smoke-convex.ts" | tee "$work/convex-restore-smoke.log" ``` Verify: diff --git a/scripts/release-gate-smoke.ts b/scripts/release-gate-smoke.ts index f02de67..dd65e04 100644 --- a/scripts/release-gate-smoke.ts +++ b/scripts/release-gate-smoke.ts @@ -51,11 +51,34 @@ try { }); await waitForServer(base, releaseId); const headers = { origin: base, 'content-type': 'application/json' }; - const chat = await fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) }); + const chatRequest = () => fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) }); + const chat = await chatRequest(); await assertStatus(chat, 503, 'assistant dispatch'); if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing'); - const sync = await fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } }); + // Chat retains its existing 30-request window for anonymous IPs. + for (let attempt = 2; attempt <= 30; attempt += 1) { + const response = await chatRequest(); + await assertStatus(response, 503, `anonymous chat denial ${attempt}`); + await response.text(); + } + const chatLimited = await chatRequest(); + await assertStatus(chatLimited, 429, 'anonymous chat rate limit'); + await chatLimited.text(); + const syncRequest = () => fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } }); + const sync = await syncRequest(); await assertStatus(sync, 503, 'asset sync'); + await sync.text(); + // The pre-session anonymous sync bucket allows exactly six denials; the + // seventh request is rate-limited. This is intentionally isolated from the + // authenticated trap subprocess below. + for (let attempt = 2; attempt <= 6; attempt += 1) { + const response = await syncRequest(); + await assertStatus(response, 503, `anonymous sync denial ${attempt}`); + await response.text(); + } + const syncLimited = await syncRequest(); + await assertStatus(syncLimited, 429, 'anonymous sync rate limit'); + await syncLimited.text(); const token = await fetch(`${base}/api/session/token`); await assertStatus(token, 503, 'session token'); const characters = await fetch(`${base}/characters`); diff --git a/src/routes/api/chat/+server.ts b/src/routes/api/chat/+server.ts index f9d6496..3115db2 100644 --- a/src/routes/api/chat/+server.ts +++ b/src/routes/api/chat/+server.ts @@ -17,7 +17,7 @@ export const POST: RequestHandler = async (event) => { return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); } const current = await session(event); - protectMutation(event, `chat:${current?.userId ?? event.getClientAddress()}`); + if (current) protectMutation(event, `chat:${current.userId}`); const parsed = schema.safeParse(await boundedJson(event)); if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 }); const { message, requestId } = parsed.data; diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index 4ceb8db..cbdb0f2 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -13,7 +13,7 @@ export const POST: RequestHandler = async (event) => { return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); } const current = await session(event); - protectMutation(event, `sync:${current?.userId ?? event.getClientAddress()}`, 6); + if (current) protectMutation(event, `sync:${current.userId}`, 6); if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 }); try { const result = await synchronize(current.userId); diff --git a/tests/workflow-config.test.ts b/tests/workflow-config.test.ts index 5658831..95f050f 100644 --- a/tests/workflow-config.test.ts +++ b/tests/workflow-config.test.ts @@ -24,8 +24,8 @@ test('Gitea workflow keeps source checks separate and holds unverified container }); test('Authenticated route limits remain user-keyed after rollout gates', () => { - expect(chatRoute).toContain('chat:${current?.userId ?? event.getClientAddress()}'); - expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}'); + expect(chatRoute).toContain('chat:${current.userId}'); + expect(syncRoute).toContain('sync:${current.userId}'); expect(chatRoute.indexOf('protectMutation')).toBeLessThan(chatRoute.indexOf('const current = await session(event)')); expect(syncRoute.indexOf('protectMutation')).toBeLessThan(syncRoute.indexOf('const current = await session(event)')); expect(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));