fix: harden residual route and restore checks

This commit is contained in:
jb
2026-09-19 10:01:23 -04:00
parent cb86497724
commit 01b8b4642b
7 changed files with 45 additions and 11 deletions
+22 -2
View File
@@ -136,7 +136,12 @@ allocated loopback ports. Do not use a production callback or provider key.
`SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It `SMOKE_ALLOW_WRITE=1`, and record its output without recording secrets. It
exercises login/session, staged publication, failed publication preserving exercises login/session, staged publication, failed publication preserving
the previous generation, tenant isolation, JWT audience checks, and local the previous generation, tenant isolation, JWT audience checks, and local
revocation. 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"
```
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
@@ -144,10 +149,12 @@ allocated loopback ports. Do not use a production callback or provider key.
```sh ```sh
export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic" export RESTORE_MARKER="$(date -u +%Y%m%dT%H%M%SZ)-synthetic"
export RESTORE_MARKER_CHARACTER_ID=998000001 export RESTORE_MARKER_CHARACTER_ID=4000000001 # reserved synthetic range
export RESTORE_MARKER_SESSION_EXPIRES_AT=$(( $(date +%s) * 1000 + 7 * 24 * 60 * 60 * 1000 ))
SMOKE_ALLOW_WRITE=1 RESTORE_MARKER_CONFIRMATION=DISPOSABLE_LOOPBACK_ONLY \ SMOKE_ALLOW_WRITE=1 RESTORE_MARKER_CONFIRMATION=DISPOSABLE_LOOPBACK_ONLY \
RESTORE_MARKER_PHASE=write RESTORE_MARKER="$RESTORE_MARKER" \ RESTORE_MARKER_PHASE=write RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
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" \
bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log" bun "$repo/scripts/smoke-restore-marker.ts" | tee "$work/marker-write.log"
``` ```
@@ -216,8 +223,11 @@ allocated loopback ports. Do not use a production callback or provider key.
```sh ```sh
RESTORE_MARKER="$RESTORE_MARKER" \ RESTORE_MARKER="$RESTORE_MARKER" \
RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \ RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
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:
@@ -241,6 +251,16 @@ allocated loopback ports. Do not use a production callback or provider key.
revokes all sessions and quarantines token records, a deployment-specific revokes all sessions and quarantines token records, a deployment-specific
credential-version fence, or an equivalent bounded mechanism. Record the credential-version fence, or an equivalent bounded mechanism. Record the
maximum stale-authority window and the decision before reopening traffic. maximum stale-authority window and the decision before reopening traffic.
If implemented for the rehearsal, run the marker check before its explicit
expiry window:
```sh
RESTORE_MARKER="$RESTORE_MARKER" RESTORE_MARKER_CHARACTER_ID="$RESTORE_MARKER_CHARACTER_ID" \
RESTORE_MARKER_SESSION_EXPIRES_AT="$RESTORE_MARKER_SESSION_EXPIRES_AT" \
CONVEX_SELF_HOSTED_URL="http://127.0.0.1:$CONVEX_PORT" \
RESTORE_MARKER_PHASE=expect-invalidated bun "$repo/scripts/smoke-restore-marker.ts"
```
Grant rotation/quarantine behavior is unexecuted until that mechanism Grant rotation/quarantine behavior is unexecuted until that mechanism
exists; no synthetic provider result can substitute for it. exists; no synthetic provider result can substitute for it.
+12 -7
View File
@@ -4,6 +4,7 @@ import { hashSecret } from '../src/lib/server/security';
import { import {
parseRestoreMarkerPhase, parseRestoreMarkerPhase,
validateRestoreMarkerTarget, validateRestoreMarkerTarget,
SYNTHETIC_CHARACTER_ID_MIN,
validateRestoreMarkerWrite, validateRestoreMarkerWrite,
} from '../src/lib/server/restore-marker'; } from '../src/lib/server/restore-marker';
@@ -15,9 +16,13 @@ validateRestoreMarkerWrite({
allowWrite: process.env.SMOKE_ALLOW_WRITE, allowWrite: process.env.SMOKE_ALLOW_WRITE,
confirmation: process.env.RESTORE_MARKER_CONFIRMATION, confirmation: process.env.RESTORE_MARKER_CONFIRMATION,
}); });
const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? '998000001'); const characterId = Number(process.env.RESTORE_MARKER_CHARACTER_ID ?? String(SYNTHETIC_CHARACTER_ID_MIN + 1));
const expiryRaw = process.env.RESTORE_MARKER_SESSION_EXPIRES_AT;
const sessionExpiresAt = expiryRaw === undefined ? Date.now() + 7 * 24 * 60 * 60_000 : Number(expiryRaw);
if (!marker || !/^[A-Za-z0-9._-]{8,100}$/.test(marker)) throw new Error('RESTORE_MARKER must be a synthetic 8–100 character identifier'); if (!marker || !/^[A-Za-z0-9._-]{8,100}$/.test(marker)) throw new Error('RESTORE_MARKER must be a synthetic 8–100 character identifier');
if (!Number.isSafeInteger(characterId) || characterId < 90_000_000) throw new Error('RESTORE_MARKER_CHARACTER_ID must be a synthetic safe integer'); if (!Number.isSafeInteger(characterId) || characterId < SYNTHETIC_CHARACTER_ID_MIN) throw new Error(`RESTORE_MARKER_CHARACTER_ID must be a reserved synthetic ID >= ${SYNTHETIC_CHARACTER_ID_MIN}`);
if (!Number.isSafeInteger(sessionExpiresAt) || sessionExpiresAt <= Date.now()) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT must be a future millisecond timestamp');
if (phase !== 'write' && expiryRaw === undefined) throw new Error('RESTORE_MARKER_SESSION_EXPIRES_AT is required when verifying or invalidating a marker');
const store = new ConvexStore(target.toString(), process.env.CONVEX_BRIDGE_SECRET!); const store = new ConvexStore(target.toString(), process.env.CONVEX_BRIDGE_SECRET!);
const sessionHash = hashSecret(`restore-marker-session:${marker}`); const sessionHash = hashSecret(`restore-marker-session:${marker}`);
const ownerHash = hashSecret(`restore-marker-owner:${marker}`); const ownerHash = hashSecret(`restore-marker-owner:${marker}`);
@@ -30,20 +35,20 @@ if (phase === 'write') {
ownerHash, ownerHash,
scopes: ['esi-assets.read_assets.v1'], scopes: ['esi-assets.read_assets.v1'],
encryptedTokens: `restore-marker-placeholder:${marker}`, encryptedTokens: `restore-marker-placeholder:${marker}`,
tokenExpiresAt: now + 60_000, tokenExpiresAt: sessionExpiresAt,
sessionHash, sessionHash,
sessionExpiresAt: now + 60 * 60_000, sessionExpiresAt,
}); });
console.log(`Restore marker written: character=${characterId}, user=${user.userId}`); console.log(`Restore marker written: character=${characterId}, user=${user.userId}, expiresAt=${sessionExpiresAt}`);
} else if (phase === 'verify') { } else if (phase === 'verify') {
const session = await store.session(sessionHash); const session = await store.session(sessionHash);
assert(session, 'restore marker session is missing'); assert(session, 'restore marker session is missing');
const characters = await store.characters(session.userId); const characters = await store.characters(session.userId);
assert(characters.some((character) => character.id === characterId && character.name === `Restore marker ${marker}`), 'restore marker character is missing or changed'); assert(characters.some((character) => character.id === characterId && character.name === `Restore marker ${marker}`), 'restore marker character is missing or changed');
console.log(`Restore marker verified: character=${characterId}, user=${session.userId}`); console.log(`Restore marker verified: character=${characterId}, user=${session.userId}, expiresAt=${sessionExpiresAt}`);
} else if (phase === 'expect-invalidated') { } else if (phase === 'expect-invalidated') {
assert.equal(await store.session(sessionHash), null, 'restore marker session remains usable; invalidation is incomplete'); assert.equal(await store.session(sessionHash), null, 'restore marker session remains usable; invalidation is incomplete');
console.log(`Restore marker invalidation verified: character=${characterId}`); console.log(`Restore marker invalidation verified: character=${characterId}, beforeExpiresAt=${sessionExpiresAt}`);
} else { } else {
throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated'); throw new Error('RESTORE_MARKER_PHASE must be write, verify, or expect-invalidated');
} }
+1
View File
@@ -1,4 +1,5 @@
export const RESTORE_MARKER_CONFIRMATION = 'DISPOSABLE_LOOPBACK_ONLY'; export const RESTORE_MARKER_CONFIRMATION = 'DISPOSABLE_LOOPBACK_ONLY';
export const SYNTHETIC_CHARACTER_ID_MIN = 4_000_000_000;
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']); const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
export type RestoreMarkerPhase = 'verify' | 'write' | 'expect-invalidated'; export type RestoreMarkerPhase = 'verify' | 'write' | 'expect-invalidated';
+3 -2
View File
@@ -10,8 +10,9 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release
const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() }); const schema = z.object({ message: z.string().trim().min(1).max(2000), requestId: z.uuid() });
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const config = configuration(); const config = configuration();
// Keep disabled routes from touching Convex/session/provider state. The // Bound origin/session work by the existing anonymous IP bucket first. The
// normal user-keyed limiter is restored after session resolution below. // normal authenticated user-keyed limiter is restored after resolution.
protectMutation(event, `chat:${event.getClientAddress()}`);
if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) { if (!capabilityEnabled('assistant_dispatch', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 }); return json({ error: capabilityDisabledReason('assistant_dispatch') }, { status: 503 });
} }
+3
View File
@@ -6,6 +6,9 @@ import { capabilityDisabledReason, capabilityEnabled } from '$lib/server/release
import { configuration } from '$lib/server/config'; import { configuration } from '$lib/server/config';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const config = configuration(); const config = configuration();
// Bound origin/session work by the existing anonymous IP bucket first. The
// normal authenticated user-keyed limiter is restored after resolution.
protectMutation(event, `sync:${event.getClientAddress()}`, 6);
if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) { if (!capabilityEnabled('asset_sync', config.capabilityAllowlist)) {
return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 }); return json({ error: capabilityDisabledReason('asset_sync') }, { status: 503 });
} }
+2
View File
@@ -2,6 +2,7 @@ import { expect, test } from 'bun:test';
import { import {
parseRestoreMarkerPhase, parseRestoreMarkerPhase,
RESTORE_MARKER_CONFIRMATION, RESTORE_MARKER_CONFIRMATION,
SYNTHETIC_CHARACTER_ID_MIN,
validateRestoreMarkerTarget, validateRestoreMarkerTarget,
validateRestoreMarkerWrite, validateRestoreMarkerWrite,
} from '../src/lib/server/restore-marker'; } from '../src/lib/server/restore-marker';
@@ -16,6 +17,7 @@ test('restore marker rejects non-loopback targets before any backend call', () =
expect(() => validateRestoreMarkerTarget('https://production.example.test')).toThrow('loopback'); expect(() => validateRestoreMarkerTarget('https://production.example.test')).toThrow('loopback');
expect(() => validateRestoreMarkerTarget('http://192.0.2.10:3210')).toThrow('loopback'); expect(() => validateRestoreMarkerTarget('http://192.0.2.10:3210')).toThrow('loopback');
expect(validateRestoreMarkerTarget('http://127.0.0.1:3210').hostname).toBe('127.0.0.1'); expect(validateRestoreMarkerTarget('http://127.0.0.1:3210').hostname).toBe('127.0.0.1');
expect(SYNTHETIC_CHARACTER_ID_MIN).toBeGreaterThan(2_000_000_000);
}); });
test('restore marker requires explicit disposable confirmation for writes', () => { test('restore marker requires explicit disposable confirmation for writes', () => {
+2
View File
@@ -26,6 +26,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 ?? event.getClientAddress()}');
expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}'); expect(syncRoute).toContain('sync:${current?.userId ?? event.getClientAddress()}');
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)')); expect(chatRoute.indexOf("capabilityEnabled('assistant_dispatch'")).toBeLessThan(chatRoute.indexOf('const current = await session(event)'));
expect(syncRoute.indexOf("capabilityEnabled('asset_sync'")).toBeLessThan(syncRoute.indexOf('const current = await session(event)')); expect(syncRoute.indexOf("capabilityEnabled('asset_sync'")).toBeLessThan(syncRoute.indexOf('const current = await session(event)'));
expect(layoutRoute).toContain('assistant_dispatch'); expect(layoutRoute).toContain('assistant_dispatch');