fix: isolate anonymous rollout rate limits

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent 01b8b4642b
commit 6af610d9ce
6 changed files with 38 additions and 22 deletions
+1 -1
View File
@@ -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. 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 ## Structure
+8 -15
View File
@@ -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 configuration against the restored instance using this same in-memory key
is the credential continuity check. is the credential continuity check.
3. Run the existing synthetic write smoke against this backend, with 3. The legacy `scripts/smoke-convex.ts` exercises useful synthetic writes,
`SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It but currently accepts an arbitrary `CONVEX_SELF_HOSTED_URL` when write is
exercises login/session, staged publication, failed publication preserving enabled. Do **not** execute it as part of this runbook; it remains
the previous generation, tenant isolation, JWT audience checks, and local unexecuted evidence until it is wrapped with the same loopback target,
revocation: explicit disposable confirmation, and key scoping as the marker tool.
```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"
```
Then write a deterministic marker that can be looked up after restore. Use 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 a fresh marker and synthetic character ID for every rehearsal; do not use a
random-only smoke result as proof of record survival: 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 bun "$repo/scripts/configure-convex.ts" >/dev/null
``` ```
3. Verify the deterministic marker before running any fresh smoke, then run 3. Verify the deterministic marker before any other restore evidence. The
the synthetic Convex smoke with fresh IDs: legacy synthetic Convex smoke remains unexecuted for the safety reason
above; do not substitute its arbitrary-target behavior for restore proof:
```sh ```sh
RESTORE_MARKER="$RESTORE_MARKER" \ 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" \ RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \ CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
RESTORE_MARKER_PHASE=verify bun "$repo/scripts/smoke-restore-marker.ts" 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: Verify:
+25 -2
View File
@@ -51,11 +51,34 @@ try {
}); });
await waitForServer(base, releaseId); await waitForServer(base, releaseId);
const headers = { origin: base, 'content-type': 'application/json' }; 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'); await assertStatus(chat, 503, 'assistant dispatch');
if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing'); 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 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`); const token = await fetch(`${base}/api/session/token`);
await assertStatus(token, 503, 'session token'); await assertStatus(token, 503, 'session token');
const characters = await fetch(`${base}/characters`); const characters = await fetch(`${base}/characters`);
+1 -1
View File
@@ -17,7 +17,7 @@ export const POST: RequestHandler = async (event) => {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
} }
const current = await session(event); 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)); 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 }); if (!parsed.success) return json({ error: 'Provide a message (1–2000 characters) and a UUID requestId.' }, { status: 400 });
const { message, requestId } = parsed.data; const { message, requestId } = parsed.data;
+1 -1
View File
@@ -13,7 +13,7 @@ export const POST: RequestHandler = async (event) => {
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
} }
const current = await session(event); 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 }); if (!current) return json({ error: 'Connect an EVE character first.' }, { status: 401 });
try { try {
const result = await synchronize(current.userId); const result = await synchronize(current.userId);
+2 -2
View File
@@ -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', () => { test('Authenticated route limits remain user-keyed after rollout gates', () => {
expect(chatRoute).toContain('chat:${current?.userId ?? event.getClientAddress()}'); expect(chatRoute).toContain('chat:${current.userId}');
expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}'); expect(syncRoute).toContain('sync:${current.userId}');
expect(chatRoute.indexOf('protectMutation')).toBeLessThan(chatRoute.indexOf('const current = await session(event)')); 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(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)')); expect(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));