mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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>
206 lines
5.0 KiB
TypeScript
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
|
|
}
|
|
}
|