From fd72a8926060319cb8aa586cccf8942b341602e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:46:38 -0700 Subject: [PATCH] fix: bound Relay desktop setup handshakes (#10260) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../relay/relay-control-client.test.ts | 66 +++++++++++++++++++ .../runtime/relay/relay-control-client.ts | 28 ++++++++ .../runtime/relay/relay-http-client.test.ts | 43 ++++++++++++ src/main/runtime/relay/relay-http-client.ts | 7 ++ 4 files changed, 144 insertions(+) diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index 415b4583d46..d06a7702169 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -97,6 +97,72 @@ describe('RelayControlClient', () => { ) }) + it('rejects a control handshake that never receives a proof response', async () => { + const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + servers.push(server) + await new Promise((resolve) => server.once('listening', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP relay test server') + } + const keypair = nacl.box.keyPair() + const client = new RelayControlClient({ + cellUrl: `http://127.0.0.1:${address.port}`, + relayJwt: 'scoped-token', + relayHostId: createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16), + assignmentEpoch: 1, + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + appVersion: '1.2.3', + onConnectionOpen: vi.fn(), + onDrain: vi.fn(), + onClose: vi.fn(), + connectDeadlineMs: 20 + }) + clients.push(client) + + await expect(client.connect()).rejects.toThrow('relay_control_connect_timeout') + }) + + it('settles an opening control immediately when ownership closes', async () => { + const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) + servers.push(server) + await new Promise((resolve) => server.once('listening', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('expected TCP relay test server') + } + const keypair = nacl.box.keyPair() + const client = new RelayControlClient({ + cellUrl: `http://127.0.0.1:${address.port}`, + relayJwt: 'scoped-token', + relayHostId: createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16), + assignmentEpoch: 1, + identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' }, + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + appVersion: '1.2.3', + onConnectionOpen: vi.fn(), + onDrain: vi.fn(), + onClose: vi.fn() + }) + clients.push(client) + const accepted = new Promise((resolve) => { + server.once('connection', () => resolve()) + }) + const connecting = client.connect() + + await accepted + client.closeNow() + + await expect(connecting).rejects.toThrow('relay_control_closed') + }) + it('proves the host key and drives control/data commands without URL credentials', async () => { const server = new WebSocketServer({ port: 0, perMessageDeflate: false }) servers.push(server) diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 44f3d65798c..f4ba141ae0d 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -34,8 +34,11 @@ type RelayControlClientOptions = { onDrain: (message: RelayDrainMessage) => void onClose: (code: number) => void createSocket?: (url: string, relayJwt: string) => WebSocket + connectDeadlineMs?: number } +const RELAY_CONTROL_CONNECT_DEADLINE_MS = 15_000 + function controlWebSocketUrl(cellUrl: string): { origin: string; url: string } { const parsed = new URL(cellUrl) if (parsed.pathname !== '/' || parsed.search || parsed.hash) { @@ -62,6 +65,7 @@ export class RelayControlClient { private state: RelayControlState = 'idle' private connectResolve: ((ack: RelayHostHelloAckMessage) => void) | null = null private connectReject: ((error: Error) => void) | null = null + private connectTimer: ReturnType | null = null constructor(options: RelayControlClientOptions) { this.options = options @@ -100,6 +104,12 @@ export class RelayControlClient { } }) socket.once('close', (code) => this.handleClose(code)) + // Recovery cannot advance while an upgrade/proof promise remains pending forever. + this.connectTimer = setTimeout( + () => this.expireConnect(), + this.options.connectDeadlineMs ?? RELAY_CONTROL_CONNECT_DEADLINE_MS + ) + this.connectTimer.unref() return new Promise((resolve, reject) => { this.connectResolve = resolve this.connectReject = reject @@ -153,7 +163,12 @@ export class RelayControlClient { } closeNow(): void { + const wasConnecting = this.state === 'opening' || this.state === 'proving' this.state = 'closed' + if (wasConnecting) { + this.connectReject?.(new Error('relay_control_closed')) + this.clearConnectPromise() + } this.requests.rejectAll(new Error('relay_control_closed')) this.socket?.terminate() this.socket = null @@ -277,7 +292,20 @@ export class RelayControlClient { this.options.onClose(code) } + private expireConnect(): void { + if (this.state !== 'opening' && this.state !== 'proving') { + return + } + this.connectReject?.(new Error('relay_control_connect_timeout')) + this.clearConnectPromise() + this.socket?.terminate() + } + private clearConnectPromise(): void { + if (this.connectTimer) { + clearTimeout(this.connectTimer) + this.connectTimer = null + } this.connectResolve = null this.connectReject = null } diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 57beb527497..9a0590adb74 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -53,6 +53,49 @@ describe('relay HTTP client', () => { expect(fetch.mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: 'Bearer scoped-token' }) + expect(fetch.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + }) + + it('aborts a blackholed assignment request so recovery can retry', async () => { + const fetch = vi.fn( + async (_url, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }) + ) + + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + requestDeadlineMs: 5, + fetch + }) + ).rejects.toMatchObject({ name: 'TimeoutError' }) + }) + + it('aborts a blackholed token exchange so recovery can retry', async () => { + const keypair = nacl.box.keyPair() + const fetch = vi.fn( + async (_url, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }) + ) + + await expect( + exchangeRelayAuthorization({ + endpoint: 'https://auth.example/v1/desktop/auth/relay-token', + accessToken: 'ordinary-access-token', + keypair: { + ...keypair, + publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') + }, + requestDeadlineMs: 5, + fetch + }) + ).rejects.toMatchObject({ name: 'TimeoutError' }) }) it('rejects data-plane supplied non-origin URLs', async () => { diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index 2dc135ee6bf..a9b1120d784 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -3,6 +3,8 @@ import { z } from 'zod' import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' +const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 + const RelayTokenResponseSchema = z .object({ relayToken: z @@ -71,6 +73,7 @@ export async function exchangeRelayAuthorization(input: { accessToken: string keypair: E2EEKeypair fetch?: typeof globalThis.fetch + requestDeadlineMs?: number }): Promise { const relayHostId = deriveRelayHostId(input.keypair.publicKey) const response = await (input.fetch ?? globalThis.fetch)(input.endpoint, { @@ -79,6 +82,8 @@ export async function exchangeRelayAuthorization(input: { authorization: `Bearer ${input.accessToken}`, 'content-type': 'application/json' }, + // A blackholed request must settle so the coordinator can advance its bounded retry state. + signal: AbortSignal.timeout(input.requestDeadlineMs ?? RELAY_HTTP_REQUEST_DEADLINE_MS), body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) }) if (!response.ok) { @@ -97,6 +102,7 @@ export async function requestRelayAssignment(input: { relayToken: string relayHostId: string fetch?: typeof globalThis.fetch + requestDeadlineMs?: number }): Promise { if (!isAllowedRelayOrigin(input.directorUrl)) { throw new RelayHttpError('assignment', 400) @@ -107,6 +113,7 @@ export async function requestRelayAssignment(input: { authorization: `Bearer ${input.relayToken}`, 'content-type': 'application/json' }, + signal: AbortSignal.timeout(input.requestDeadlineMs ?? RELAY_HTTP_REQUEST_DEADLINE_MS), body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) }) if (!response.ok) {