Files
orca/src/main/ssh/ssh-stream-reader-memory.ts
T
NeilandOrca ab04617ae7 fix(ssh): bound relay stream ingress
Retained state on the SSH stream path was unbounded in three places.

Stream assembly and pre-metadata buffering
- `ssh-stream-reader-memory.ts` adds two shared budgets: an aggregate assembly
  ceiling (128 MiB across all in-flight streams) and a pre-metadata frame budget
  (256 frames / 32 MiB). Every reader sees all stream frames and cannot filter by
  streamId until its own sentinel resolves, so a foreign flood previously grew
  unbounded while a reader waited.
- The per-frame oversize check is load-bearing beyond memory: without it the
  eviction loop can never satisfy its exit condition on an empty buffer and spins
  forever. Covered by test.
- Assembly is split into `ssh-file-stream-assembler.ts` and
  `ssh-git-response-stream-assembler.ts`, both reserving from the shared budget
  and releasing on every terminal path.
- Git response stream markers are validated against client-side byte and chunk
  ceilings before allocation, so a relay-advertised size cannot force the
  allocation directly.

Frame decoder
- An oversized frame header is now answered by retaining only the 13-byte header
  and stream-discarding the advertised payload, so a malicious length field
  cannot pin up to 4 GiB waiting for bytes that never arrive.
- Adversarially tiny transport fragments are coalesced pairwise, bounding the
  chunk-wrapper array without copying ordinary chunks.
- Frame encode/decode route through the bounded stringify and parse helpers.

Multiplexer dispose
- Dispose handlers unsubscribe themselves while running, so iterating the live
  array skipped every other handler and stranded the assembly budget of half the
  in-flight streams. Detach before dispatch.

WSL hook fs bridge
- The bridge and its host adapter now pass and enforce the directory-entry and
  read-byte ceilings the shared contract already declared, so a listing of an
  arbitrarily large guest directory is refused at the source rather than
  serialized into a multi-megabyte frame.

All new bounds are mutation-tested: reverting each one fails at least one test.

Co-authored-by: Orca <help@stably.ai>
2026-07-26 23:15:45 -07:00

206 lines
5.0 KiB
TypeScript

export const STREAM_READER_INACTIVITY_TIMEOUT_MS = 30_000
export const MAX_PRE_METADATA_STREAM_FRAMES = 64
export const MAX_PRE_METADATA_STREAM_ENCODED_BYTES = 8 * 1024 * 1024
export const MAX_SSH_PRE_METADATA_STREAM_FRAMES = 256
export const MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES = 32 * 1024 * 1024
export const MAX_SSH_STREAM_ASSEMBLY_BYTES = 128 * 1024 * 1024
export type BufferedStreamFrame = {
kind: 'chunk' | 'end' | 'error'
params: Record<string, unknown>
}
type RetainedStreamFrame = {
frame: BufferedStreamFrame
encodedBytes: number
release: () => void
}
export class SshPreMetadataStreamBudget {
private frames = 0
private bytes = 0
constructor(
readonly maxFrames: number,
readonly maxBytes: number
) {}
reserve(bytes: number): (() => void) | null {
if (
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
this.frames >= this.maxFrames ||
bytes > this.maxBytes - this.bytes
) {
return null
}
this.frames += 1
this.bytes += bytes
let released = false
return () => {
if (released) {
return
}
released = true
this.frames -= 1
this.bytes -= bytes
}
}
get retainedFrames(): number {
return this.frames
}
get retainedBytes(): number {
return this.bytes
}
}
export const defaultSshPreMetadataStreamBudget = new SshPreMetadataStreamBudget(
MAX_SSH_PRE_METADATA_STREAM_FRAMES,
MAX_SSH_PRE_METADATA_STREAM_RETAINED_BYTES
)
export class PreMetadataStreamFrameBuffer {
private frames: RetainedStreamFrame[] = []
private encodedBytes = 0
constructor(private readonly budget: SshPreMetadataStreamBudget) {}
push(frame: BufferedStreamFrame): boolean {
const retainedFrame = retainStreamFrameFields(frame)
const frameBytes = retainedStreamFrameBytes(retainedFrame)
if (frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES) {
return false
}
while (
this.frames.length >= MAX_PRE_METADATA_STREAM_FRAMES ||
this.encodedBytes + frameBytes > MAX_PRE_METADATA_STREAM_ENCODED_BYTES
) {
this.shift()
}
let release = this.budget.reserve(frameBytes)
while (!release && this.frames.length > 0) {
this.shift()
release = this.budget.reserve(frameBytes)
}
if (!release) {
return false
}
this.frames.push({ frame: retainedFrame, encodedBytes: frameBytes, release })
this.encodedBytes += frameBytes
return true
}
shift(): BufferedStreamFrame | undefined {
const retained = this.frames.shift()
if (retained) {
this.encodedBytes -= retained.encodedBytes
retained.release()
}
return retained?.frame
}
get length(): number {
return this.frames.length
}
clear(): void {
while (this.frames.length > 0) {
this.shift()
}
}
}
function retainStreamFrameFields(frame: BufferedStreamFrame): BufferedStreamFrame {
const { params } = frame
if (frame.kind === 'chunk') {
return {
kind: frame.kind,
params: {
...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}),
...(typeof params.seq === 'number' ? { seq: params.seq } : {}),
...(typeof params.data === 'string' ? { data: params.data } : {})
}
}
}
if (frame.kind === 'error') {
return {
kind: frame.kind,
params: {
...(typeof params.streamId === 'number' ? { streamId: params.streamId } : {}),
...(typeof params.message === 'string' ? { message: params.message } : {}),
...(typeof params.code === 'string' ? { code: params.code } : {})
}
}
}
return {
kind: frame.kind,
params: typeof params.streamId === 'number' ? { streamId: params.streamId } : {}
}
}
function retainedStreamFrameBytes(frame: BufferedStreamFrame): number {
let bytes = 0
for (const value of Object.values(frame.params)) {
if (typeof value === 'string') {
bytes += Buffer.byteLength(value, 'utf-8')
}
}
return bytes
}
export class SshStreamAssemblyBudget {
private retained = 0
constructor(readonly maxBytes: number) {}
reserve(bytes: number): (() => void) | null {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.maxBytes - this.retained) {
return null
}
this.retained += bytes
let released = false
return () => {
if (released) {
return
}
released = true
this.retained -= bytes
}
}
get retainedBytes(): number {
return this.retained
}
}
export const defaultSshStreamAssemblyBudget = new SshStreamAssemblyBudget(
MAX_SSH_STREAM_ASSEMBLY_BYTES
)
export function base64EncodedLength(rawBytes: number): number {
return Math.ceil(rawBytes / 3) * 4
}
export function createStreamInactivityDeadline(
timeoutMs: number,
onTimeout: () => void
): { reset: () => void; clear: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null
const clear = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
return {
reset: () => {
clear()
timer = setTimeout(onTimeout, timeoutMs)
timer.unref?.()
},
clear
}
}