fix: harden residual route and restore checks
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user