Files
orca/src/relay/protocol.ts
T
Neil 78a17bb24d fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon (#19879)
* fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon

parseHandshakeMessage returned whatever JSON.parse produced, and the daemon
interpolates the peer's version into a log line before any credential check.
A version that is an object with a non-callable toString throws TypeError
there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its
synchronous dispatch in try/finally with no catch, so the throw escaped
feed(), escaped the socket data handler, and reached uncaughtException: the
relay daemon exited and every PTY and agent session it held died with it.

Two layers, because only the second closes the class:

- parseHandshakeMessage now requires the string fields each arm carries
  (version; expected/got) and rejects a non-object payload. Both readers
  share the parser, so neither side can interpolate a non-string again.
- FrameDecoder contains a frame owner that throws on the synchronous turn
  the same way it already contained one on a continuation turn: reset the
  residue and report one FrameDecoderContinuationError to onError. Every
  owner's onError already closes its own connection, so any future throw
  of this shape costs one connection instead of the process.

The relay CLI channel gains an explicit onError so a malformed reply still
ends that one-shot command instead of parking it.

* fix(relay): keep the diagnostic the refusal path exists to produce

Two error paths that destroy their own evidence.

`parseHandshakeMessage`'s unknown-type refusal interpolated `String(t)` on a
peer-supplied value: `{"type":{"toString":1}}` makes String() throw "Cannot
convert object to primitive value", so the refusal arrives without naming what
was refused. `describeRelayProtocolVersion` guards this exact hazard two files
away; the sibling was missed.

`runRelayOrcaCliChannel`'s new `onDecodeError` wrote to stderr and then exited
synchronously. stderr is async on a pipe transport, so the one line recording
why the command died could be dropped — the reason relay-handshake.ts already
exits inside its write callback.

* fix(relay): prove the optional handshake field too, not just the required ones

The parser refuses a non-string `version`, `expected` and `got`, then returns the
object with `endpointCredential` unproved — the most pre-auth field on the frame.
It is safe today only by accident: its one reader compares it, and a non-string
loses that comparison. Nothing holds that shape in place, and the next reader to
put it in a log line reinstates the template-literal throw this function exists
to stop.

Present-but-not-a-string is now refused at the parser. Absent stays absent: a
bridge presenting no credential is the common case, and refusing it would close
every unauthenticated-endpoint connection.

Wire-visible delta, deliberate: a peer sending a non-string credential used to get
`orca-relay-handshake-credential-mismatch` and exit 43; it now gets a bare close.
No first-party client can reach it — `runConnectHandshake` types the parameter
`string` and omits it when falsy — and a bare close is the right answer to a frame
that was malformed before any credential was checked.

* fix(relay): carry the SAFETY: rationale main's casting gate now requires

Main gained a `typescript/consistent-type-assertions` scan while this branch sat 432
commits behind, so every `as` the branch touches lands as a new finding. The parser is
the one place the handshake shape is proved, so each cast names the check that earns it,
and the hostile-frame cast in the round-trip test names the fact that it is a deliberate
lie the type system cannot describe.

* test(relay): annotate the hostile handshake frame instead of suppressing a cast

JSON.parse answers `any`, so a typed const expresses the same deliberate lie the
assertion did and the casting gate has nothing to flag. One fewer suppression.
2026-09-17 21:21:27 -07:00

234 lines
9.4 KiB
TypeScript

// Self-contained relay protocol — mirrors src/main/ssh/relay-protocol.ts
// but has no Electron dependencies. Deployed standalone to remote hosts.
import {
FrameDecoder,
FrameDecoderContinuationError,
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
FRAME_DECODER_MAX_FRAMES_PER_TURN,
FRAME_DECODER_MAX_BYTES_PER_TURN,
FRAME_DECODER_MAX_TURN_MS,
FRAME_DECODER_MAX_RETAINED_BYTES
} from './relay-frame-decoder'
export {
FrameDecoder,
FrameDecoderContinuationError,
HEADER_LENGTH,
MAX_MESSAGE_SIZE,
FRAME_DECODER_MAX_FRAMES_PER_TURN,
FRAME_DECODER_MAX_BYTES_PER_TURN,
FRAME_DECODER_MAX_TURN_MS,
FRAME_DECODER_MAX_RETAINED_BYTES
}
export type { DecodedFrame, FrameDecoderOptions } from './relay-frame-decoder'
export const RELAY_VERSION = '0.1.0'
export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n`
export const MessageType = {
Regular: 1,
Handshake: 2,
KeepAlive: 9
} as const
// Why: a pre-dispatcher envelope on a freshly-accepted Unix socket. The daemon
// reads exactly one Handshake frame before attaching the JSON-RPC dispatcher,
// to refuse mismatched-version --connect bridges that would otherwise drive a
// stale daemon.
export type HandshakeMessage =
| { type: 'orca-relay-handshake'; version: string; endpointCredential?: string }
| { type: 'orca-relay-handshake-ok'; version: string }
| { type: 'orca-relay-handshake-mismatch'; expected: string; got: string }
// Why a distinct reply: the bridge exits with its own code so the client can tell a refused
// credential from a crashed relay. Old bridges reject the unknown type and exit 1 pre-sentinel.
| { type: 'orca-relay-handshake-credential-mismatch' }
export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer {
const payload = Buffer.from(JSON.stringify(msg), 'utf-8')
return encodeFrame(MessageType.Handshake, 0, 0, payload)
}
// Why the fields are checked and not just the type: this frame arrives before any credential, and
// both sides interpolate its version fields into log lines. `JSON.parse` can produce values a
// template literal throws on, so anything that reaches a reader must already be a string.
const HANDSHAKE_STRING_FIELDS: Readonly<Record<HandshakeMessage['type'], readonly string[]>> = {
'orca-relay-handshake': ['version'],
'orca-relay-handshake-ok': ['version'],
'orca-relay-handshake-mismatch': ['expected', 'got'],
'orca-relay-handshake-credential-mismatch': []
}
// Optional fields are peer-supplied too, so the parser only proves the type of what it returns if
// it refuses a present-but-wrong one. `endpointCredential` survives today only because its single
// reader compares it and never interpolates it; the next reader to log it would restore the bug
// this function exists to stop. Absent stays absent — refusing that would break a bridge that
// legitimately presents no credential.
const HANDSHAKE_OPTIONAL_STRING_FIELDS: Readonly<
Record<HandshakeMessage['type'], readonly string[]>
> = {
'orca-relay-handshake': ['endpointCredential'],
'orca-relay-handshake-ok': [],
'orca-relay-handshake-mismatch': [],
'orca-relay-handshake-credential-mismatch': []
}
export function parseHandshakeMessage(payload: Buffer): HandshakeMessage {
const parsed: unknown = JSON.parse(payload.toString('utf-8'))
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('Handshake payload is not an object')
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the typeof/null guard directly above is exactly what makes this an index-able object; every read below still proves its own field.
const msg = parsed as Record<string, unknown>
const t = msg.type
const required =
typeof t === 'string' && Object.hasOwn(HANDSHAKE_STRING_FIELDS, t)
? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reached only when Object.hasOwn proved t is a key of this record, on the same line.
HANDSHAKE_STRING_FIELDS[t as HandshakeMessage['type']]
: null
if (required === null) {
// Why typeof and not String(t): a peer-supplied `{ "type": { "toString": 1 } }` makes String()
// itself throw "Cannot convert object to primitive value", replacing the one diagnostic this
// line exists to produce.
throw new Error(`Unknown handshake type: ${typeof t === 'string' ? t : typeof t}`)
}
for (const field of required) {
if (typeof msg[field] !== 'string') {
throw new Error(`Handshake field ${field} is not a string`)
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the required === null bail above already refused every t that is not one of the four keys.
for (const field of HANDSHAKE_OPTIONAL_STRING_FIELDS[t as HandshakeMessage['type']]) {
if (msg[field] !== undefined && typeof msg[field] !== 'string') {
throw new Error(`Handshake field ${field} is not a string`)
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this is the one place the shape is proved: the type is one of the four literals and every field the union declares has been checked to be a string.
return msg as unknown as HandshakeMessage
}
export const KEEPALIVE_SEND_MS = 5_000
export const TIMEOUT_MS = 20_000
// ── Streaming constants (see docs/relay-file-stream-design.md) ─────
export const STREAM_CHUNK_SIZE = 256 * 1024
export const MAX_CONCURRENT_STREAMS = 16
/** Max unacked fs.streamChunk frames in flight per stream when the client
* requested `flowControl: 'ack'`. Bounds how many bulk bytes an interactive
* pty.data frame can queue behind on the shared SSH channel (~1MB raw) while
* keeping the pipe full across one ack round-trip on fast links. */
export const STREAM_ACK_WINDOW_CHUNKS = 4
/** Safety-valve poll interval for a pump stalled on acks: re-checks stream
* abort/staleness so a client that vanished mid-stream cannot park the pump
* (and its open file handle) forever. */
export const STREAM_ACK_STALL_RECHECK_MS = 1_000
// ── Git response streaming (see docs/relay-git-response-stream-design.md) ──
/** Serialized-JSON size above which a streamable git response (diff family +
* exec) is chunked onto the bulk lane instead of one JSON-RPC frame, so a large
* diff cannot head-of-line-block interactive pty.data echo on the shared SSH
* channel. Below this, single-frame is cheaper and avoids stream overhead. */
export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024
/** Per-chunk size (UTF-8 bytes of the serialized result) for git response
* streaming. Independent from STREAM_CHUNK_SIZE — this offset math is not
* shared with fs streams, so tuning it here is cross-version safe as long as
* the client reassembles by concatenation (it does not depend on chunk size). */
export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024
/** Sentinel result returned in place of a large git response: the real payload
* follows as git.responseChunk frames on the bulk lane. Old relays never emit
* this, so a new client falls back to the plain result they return. */
export type GitResponseStreamMarker = {
__orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number }
}
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007,
/** Substituted for a response too large for the sink's frame capacity; the request fails
* instead of the whole link, so a caller can retry with a narrower scope. */
ResponseOverCapacity: -33008
} as const
export type JsonRpcRequest = {
jsonrpc: '2.0'
id: number
method: string
params?: Record<string, unknown>
}
export type JsonRpcResponse = {
jsonrpc: '2.0'
id: number
result?: unknown
error?: { code: number; message: string; data?: unknown }
}
export type JsonRpcNotification = {
jsonrpc: '2.0'
method: string
params?: Record<string, unknown>
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification
const JSON_RPC_PAYLOAD_BYTES = Symbol('jsonRpcPayloadBytes')
export type PreparedJsonRpcPayload = Readonly<{
byteLength: number
[JSON_RPC_PAYLOAD_BYTES]: Buffer
}>
export function encodeFrame(
type: number,
id: number,
ack: number,
payload: Buffer | Uint8Array
): Buffer {
const header = Buffer.alloc(HEADER_LENGTH)
header[0] = type
header.writeUInt32BE(id, 1)
header.writeUInt32BE(ack, 5)
header.writeUInt32BE(payload.length, 9)
return Buffer.concat([header, payload])
}
export function encodeJsonRpcFrame(msg: JsonRpcMessage, id: number, ack: number): Buffer {
return encodePreparedJsonRpcFrame(prepareJsonRpcPayload(msg), id, ack)
}
export function prepareJsonRpcPayload(msg: JsonRpcMessage): PreparedJsonRpcPayload {
const payload = Buffer.from(JSON.stringify(msg), 'utf-8')
if (payload.length > MAX_MESSAGE_SIZE) {
throw new Error(`Message too large: ${payload.length} bytes`)
}
return Object.freeze({ byteLength: payload.length, [JSON_RPC_PAYLOAD_BYTES]: payload })
}
export function encodePreparedJsonRpcFrame(
payload: PreparedJsonRpcPayload,
id: number,
ack: number
): Buffer {
return encodeFrame(MessageType.Regular, id, ack, payload[JSON_RPC_PAYLOAD_BYTES])
}
export function encodeKeepAliveFrame(id: number, ack: number): Buffer {
return encodeFrame(MessageType.KeepAlive, id, ack, Buffer.alloc(0))
}
export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage {
const text = payload.toString('utf-8')
const msg = JSON.parse(text) as JsonRpcMessage
if (msg.jsonrpc !== '2.0') {
throw new Error(`Invalid JSON-RPC version: ${String((msg as Record<string, unknown>).jsonrpc)}`)
}
return msg
}