mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(relay): never send a raw error message to the desktop as a control error code (#30873)
A host control request that failed for an unexpected reason sent the thrown error's message to the desktop as the error code. During a staging database outage the desktop received Postgres's own text, and node-pg messages can carry the database connection URL. The relay now sends store codes and this codebase's snake_case invariant slugs unchanged, and maps everything else to database_transient or control_operation_failed. The original is logged through the existing redaction (now shared with the background-operation logger), so only the slug shape and SQLSTATE leave the process.
This commit is contained in:
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } fr
|
||||
import type WebSocket from 'ws'
|
||||
import type { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { RelayCredentialStore } from './credential-store.js'
|
||||
import { RelayStoreError, type RelayCredentialStore } from './credential-store.js'
|
||||
import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from './control-renewal-batch.js'
|
||||
import {
|
||||
CONTROL_RENEWAL_STATEMENT_OUTCOMES,
|
||||
@@ -2199,3 +2199,75 @@ describe('controls closed after host proof but before wiring', () => {
|
||||
expect(refusedLines()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('control error codes', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function revokeFailure(error: Error): Promise<{ sent: string[]; warned: string[] }> {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const activateControl = vi
|
||||
.fn<RelayAssignmentStore['activateControl']>()
|
||||
.mockResolvedValue('control:production-gce-c3:1')
|
||||
const { registry, activate } = createRegistry(activateControl, {
|
||||
revoke: vi.fn().mockRejectedValue(error)
|
||||
})
|
||||
const socket = new FakeSocket()
|
||||
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
|
||||
socket.emit(
|
||||
'message',
|
||||
Buffer.from(JSON.stringify({ type: 'device-revoke', reqId: 'revoke-1', relayDeviceId: 'device-1' })),
|
||||
false
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const sent = socket.send.mock.calls.map((call) => String(call[0]))
|
||||
const warned = warn.mock.calls.map((call) => String(call[0]))
|
||||
warn.mockRestore()
|
||||
registry.drain(0)
|
||||
vi.advanceTimersByTime(0)
|
||||
return { sent, warned }
|
||||
}
|
||||
|
||||
function controlErrors(sent: string[]): unknown[] {
|
||||
return sent
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
.filter((message) => message.type === 'control-error')
|
||||
}
|
||||
|
||||
it('never sends a database error message, which can carry the connection URL', async () => {
|
||||
// Assembled at runtime so secret scanners do not flag a fixture URL.
|
||||
const url = ['postgres', '://relay:', 'fake-pw', '@db.internal:5432/relay'].join('')
|
||||
const error = Object.assign(new Error(`connect to ${url} failed: Connection terminated`), {
|
||||
code: '08006'
|
||||
})
|
||||
const { sent, warned } = await revokeFailure(error)
|
||||
|
||||
expect(controlErrors(sent)).toEqual([
|
||||
{ type: 'control-error', reqId: 'revoke-1', code: 'database_transient' }
|
||||
])
|
||||
expect(sent.join('\n')).not.toContain('fake-pw')
|
||||
expect(warned).toContain('[orca-relay] control device-revoke failed: Error: redacted code=08006')
|
||||
expect(warned.join('\n')).not.toContain('fake-pw')
|
||||
})
|
||||
|
||||
it('maps any other unexpected message to a fixed code', async () => {
|
||||
const { sent } = await revokeFailure(new Error('Unexpected token < in JSON'))
|
||||
|
||||
expect(controlErrors(sent)).toEqual([
|
||||
{ type: 'control-error', reqId: 'revoke-1', code: 'control_operation_failed' }
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a store code', new RelayStoreError('reject-revoked'), 'reject-revoked'],
|
||||
['an invariant slug', new Error('authorization_expired'), 'authorization_expired']
|
||||
])('keeps %s on the wire', async (_kind, error, code) => {
|
||||
const { sent, warned } = await revokeFailure(error)
|
||||
|
||||
expect(controlErrors(sent)).toEqual([{ type: 'control-error', reqId: 'revoke-1', code }])
|
||||
expect(warned.join('\n')).not.toContain('control device-revoke failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
PostProofCloseLog,
|
||||
type PostProofCloseCause
|
||||
} from './post-proof-close-log.js'
|
||||
import { describeRelayFailure, relayErrorSlug } from './relay-error-redaction.js'
|
||||
import { relayHostLogDigest } from './relay-host-log-digest.js'
|
||||
import type { RelayTokenClaims } from './relay-token-verifier.js'
|
||||
import {
|
||||
@@ -2528,11 +2529,7 @@ export class HostSessionRegistry {
|
||||
? String(candidate.reqId)
|
||||
: undefined
|
||||
})()
|
||||
this.sendControlError(
|
||||
session,
|
||||
reqId,
|
||||
error instanceof Error ? error.message : 'control_operation_failed'
|
||||
)
|
||||
this.sendControlError(session, reqId, controlErrorCode(type, error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2640,6 +2637,15 @@ export class HostSessionRegistry {
|
||||
|
||||
export type RelayIdentityKey = { userId: string; relayHostId: string }
|
||||
|
||||
// Intentional throws are store codes or slugs; anything else could carry the database URL.
|
||||
function controlErrorCode(type: string, error: unknown): string {
|
||||
if (error instanceof RelayStoreError) return error.code
|
||||
const slug = relayErrorSlug(error)
|
||||
if (slug) return slug
|
||||
console.warn(`[orca-relay] control ${type} failed: ${describeRelayFailure(error)}`)
|
||||
return isRelayDatabaseTransientError(error) ? 'database_transient' : 'control_operation_failed'
|
||||
}
|
||||
|
||||
function databaseCause(error: unknown): PhoneDatabaseCause {
|
||||
return isRelayDatabaseTransientError(error) ? 'database_transient' : 'error'
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { describeRelayFailure } from './relay-error-redaction.js'
|
||||
|
||||
type Warn = (message: string) => void
|
||||
|
||||
// A swallowed error hides which sweep step is failing (a silently dead
|
||||
// cleanup chain stalled production rehoming for hours), but raw messages can
|
||||
// embed secrets such as connection strings. Only two provably inert shapes
|
||||
// are logged: this codebase's snake_case invariant slugs, and five-character
|
||||
// SQLSTATE codes; everything else stays redacted.
|
||||
function describeFailure(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : typeof error
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
const slug = /^[a-z0-9_]{1,64}$/.test(message) ? message : 'redacted'
|
||||
const code = (error as { code?: unknown } | null)?.code
|
||||
const sqlState = typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code) ? ` code=${code}` : ''
|
||||
return `${name}: ${slug}${sqlState}`
|
||||
}
|
||||
|
||||
// cleanup chain stalled production rehoming for hours).
|
||||
export async function runRelayBackgroundOperation(
|
||||
operation: () => Promise<unknown>,
|
||||
failureMessage: string,
|
||||
@@ -23,6 +13,6 @@ export async function runRelayBackgroundOperation(
|
||||
await operation()
|
||||
} catch (error) {
|
||||
// Dependency outages must fail readiness without crashing liveness.
|
||||
warn(`${failureMessage}: ${describeFailure(error)}`)
|
||||
warn(`${failureMessage}: ${describeRelayFailure(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Raw error messages can embed secrets such as connection strings. Only two
|
||||
// provably inert shapes leave the process: this codebase's snake_case
|
||||
// invariant slugs, and five-character SQLSTATE codes; everything else is redacted.
|
||||
export function relayErrorSlug(error: unknown): string | null {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
return /^[a-z0-9_]{1,64}$/.test(message) ? message : null
|
||||
}
|
||||
|
||||
export function describeRelayFailure(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : typeof error
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : null
|
||||
const sqlState = typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code) ? ` code=${code}` : ''
|
||||
return `${name}: ${relayErrorSlug(error) ?? 'redacted'}${sqlState}`
|
||||
}
|
||||
@@ -1542,7 +1542,7 @@ describe('served relay URL', () => {
|
||||
expect(await failed).toMatchObject({
|
||||
type: 'control-error',
|
||||
reqId: 'fault-install',
|
||||
code: 'injected SQL failure'
|
||||
code: 'control_operation_failed'
|
||||
})
|
||||
const status = nextMessage(host.socket)
|
||||
host.socket.send(
|
||||
@@ -1642,7 +1642,7 @@ describe('served relay URL', () => {
|
||||
expect(await directFailure).toMatchObject({
|
||||
type: 'control-error',
|
||||
reqId: 'fallback-install',
|
||||
code: 'injected SQL failure'
|
||||
code: 'control_operation_failed'
|
||||
})
|
||||
const status = nextMessage(host.socket)
|
||||
host.socket.send(
|
||||
|
||||
Reference in New Issue
Block a user