Files
orca/src/main/ssh/ssh-proxy-command.ts
T
Neil b7e79b7ca6 fix(windows): one chokepoint for every child process (#15746)
* feat(process): add the Windows-correct child-process chokepoint

Six decisions have to be made every time Orca starts a child process --
console visibility, argument quoting, .cmd interpretation, binary
resolution, timeout policy, and how the tree is later terminated. POSIX
forgives all six. Windows punishes each differently, and made per-call
site across 172 files they were right in some and wrong in others.

runProcess/spawnProcess make them once:
- windowsHide unconditionally, shell:false unconditionally (shell:true
  concatenates argv unescaped and silently disables windowsHide)
- .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line,
  because Node refuses to spawn them otherwise (EINVAL)

The encoding was derived by measurement on Windows 11, not from the
docs. An embedded quote is written "" rather than \" so cmd's naive
quote count stays even -- with \" the parity flips and every later &
| < > on the line stops being data. Measured before the fix, argv
["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as
["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and
ran its remainder as a command. Each % is broken out of the quoted run
as "^%" because %VAR% expands even inside quotes.

The import-boundary test is a ratchet seeded at today's 172 files; it
only shrinks.

* fix(process): route the console-flashing spawn sites through the chokepoint

The ssh -G config probe fires on every connect and reconnect, and ssh.exe
is console-subsystem, so a GUI-subsystem parent gets a fresh visible
conhost that takes foreground -- keystrokes typed into an Orca terminal
at that moment go into the black box (#10488, #14543). Same for the
ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration
and the DPAPI cookie decrypt.

Also stops spawning powershell by bare name: PATH under Electron is not
the user's, so where policy has pruned the System32 entry the spawn fails
and the font picker silently reports five hardcoded families rather than
an error (#11771).

Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap
and kill are the chokepoint's job now. Adds runProcessSync so the sync
callers have a compliant path; without one the ratchet could never
reach zero.

The three suites that mocked child_process directly now mock runProcess,
which is the point: how a process gets started is no longer each
module's business. Ratchet 173 -> 170.

* fix(process): do not report a deliberately killed child as timed out

runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured:
a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so
does anything else that terminates the child -- and those cases set no
error at all. Reading the signal alone reports a process someone stopped
on purpose as having timed out, which callers retry.

* refactor(process): hold the ratchet as data and migrate the pwsh probes

The allowlist and the adversarial argument corpus are read only by tests,
so they were production modules in name only; they move to __fixtures__.

pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the
same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile
callback as a SIGTERM kill with no code. runProcess reports one timedOut
flag, so the helper and the reasoning behind it both go.

Its sync probe also spawned without windowsHide, which flashes a console
and steals foreground on every cold cache read.

* refactor(process): migrate five more spawn sites onto the chokepoint

Each one deletes a hand-rolled promise/timeout/kill wrapper and stops
re-deciding console visibility for itself. Ratchet 170 -> 164.

Two things this surfaced, both kept:

runProcess now accepts string chunks as well as buffers. A stream someone
called setEncoding on emits strings, and concatenating those as buffers
throws inside a data handler -- where the rejection has nowhere to go and
the caller simply hangs rather than failing.

ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS
PAM preflight passes one through from its own caller.

ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage
 pipeline detached so a timeout can reap the group with one
negative-pid SIGKILL; runProcess kills only the root, which would orphan
the plutil stages. Migrating it needs the chokepoint to own POSIX
process-group termination first -- the same guarantee job objects give on
Windows. Reverted and left on the ratchet.

* test(process): do not assert a POSIX signal on Windows

Windows has no signals, so the same deliberate kill reports an exit code
there and a signal on POSIX. What has to hold on both is that neither
shape reads as a timeout. Caught by running the suite on Windows.

(cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f)

* fix(process): settle a timed-out run even when the child ignores the kill

close only fires once the child is actually gone, so a child that traps
SIGTERM never emits it and the promise outlives its own deadline
forever. That is the same wedge shape just fixed for the process table,
and it is worse here: pwsh.ts and the snapshot reader both cache an
in-flight probe, so one unkillable child hands every later caller the
same dead promise.

After the deadline it now escalates to SIGKILL and settles regardless,
reporting timedOut with whatever output arrived.

(cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc)

* fix(process): escalate an aborted child too, not just a timed-out one

The grace escalation I added covered the timeout path and left abort on
the old one, so an aborted caller with an unkillable child still waited
forever -- the same defect, one path over. The macOS PAM preflight is a
real caller that passes an AbortSignal.

Both paths now share one stop-and-settle, and the result reports
timedOut honestly: false when the caller aborted.

(cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d)

* fix(windows): stop percent escaping from forging an escaped quote

escapePercentForCmd ran as a post-pass over the quoted string, so it
inserted a quote wherever a percent was -- including straight after a
backslash. CommandLineToArgvW reads backslash-quote as an escaped quote,
so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as
Windows paths get, and my 20-case corpus had no backslash-before-percent
entry to catch it.

Percent handling is now part of the quoting loop, where the backslash
run is known and can be doubled before the inserted quote. Two corpus
cases cover the shape.

The program path gets the same treatment. It was quoted but not
percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own
path expanded on the cmd hop.

quoteWindowsArgument no longer takes a boolean. Passing it to
values.map() handed map's index in as the flag -- which is how the first
version of this fix was written, and the corpus test caught it.

Separately: an AbortSignal that was already aborted never fires the
event, so runProcess ran the child to its full timeout for a caller who
had already given up.

(cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
2026-08-21 21:05:24 -07:00

186 lines
6.6 KiB
TypeScript

import { spawn, type ChildProcess } from 'node:child_process'
import { spawnProcess } from '../../shared/child-process/run-process'
import { Duplex } from 'node:stream'
import type { Socket as NetSocket } from 'node:net'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
import { shellEscape } from './ssh-connection-utils'
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
// Why: ProxyJump and jumpHost are syntactic sugar for ProxyCommand.
// OpenSSH internally converts `ProxyJump bastion` to
// `ProxyCommand ssh -W %h:%p bastion`. We do the same so that ssh2
// gets a single proxy spawn path regardless of how the tunnel was configured.
export type EffectiveProxy =
| { kind: 'proxy-command'; command: string }
| { kind: 'jump-host'; jumpHost: string }
export function resolveEffectiveProxy(
target: SshTarget,
resolved: SshResolvedConfig | null
): EffectiveProxy | undefined {
if (isOpenSshConfigBackedTarget(target) && resolved) {
if (resolved.proxyCommand) {
return { kind: 'proxy-command', command: resolved.proxyCommand }
}
return resolved.proxyJump ? { kind: 'jump-host', jumpHost: resolved.proxyJump } : undefined
}
if (target.proxyCommand) {
return { kind: 'proxy-command', command: target.proxyCommand }
}
if (resolved?.proxyCommand) {
return { kind: 'proxy-command', command: resolved.proxyCommand }
}
const jump = target.jumpHost || resolved?.proxyJump
if (jump) {
return { kind: 'jump-host', jumpHost: jump }
}
return undefined
}
// Why: cmd.exe has no quoting that survives arbitrary values — `%VAR%` expands
// inside quotes and `""` re-opens the unquoted state — so the only safe Windows
// expansion is to reject metacharacters instead of pretending to escape them.
// Hosts, ports and usernames never legitimately contain these.
const CMD_UNSAFE_PATTERN = /["%&|<>^\r\n]/
function cmdEscape(s: string): string {
if (CMD_UNSAFE_PATTERN.test(s)) {
throw new Error(
`ProxyCommand value cannot be safely expanded on Windows (unsupported characters): ${s}`
)
}
return `"${s}"`
}
type ShellSpawnConfig = { file: string; args: string[]; windowsVerbatimArguments: boolean }
// Why: ssh2 doesn't natively support ProxyCommand. When the SSH config
// specifies one (e.g. `cloudflared access ssh --hostname %h`), we spawn
// the command and bridge its stdin/stdout into a Duplex stream that ssh2
// uses as its transport socket via `config.sock`.
function getShellSpawnConfig(command: string): ShellSpawnConfig {
if (process.platform === 'win32') {
const comspec = process.env.ComSpec || 'cmd.exe'
// Why: mirror Node's own `shell: true` form. `/s` makes cmd.exe strip the
// outer quotes and take the rest verbatim; without verbatim arguments Node
// would backslash-escape inner quotes, which cmd.exe does not understand.
return {
file: comspec,
args: ['/d', '/s', '/c', `"${command}"`],
windowsVerbatimArguments: true
}
}
return { file: '/bin/sh', args: ['-c', command], windowsVerbatimArguments: false }
}
// Why: ProxyJump takes a comma-separated chain, but `ssh -W host:port dest`
// only tunnels through a single final hop. Preceding hops become that hop's own
// -J chain, which is how OpenSSH expands a multi-hop ProxyJump.
function jumpHostSpawnArgs(jumpHost: string, host: string, port: number): string[] {
const hops = jumpHost
.split(',')
.map((hop) => hop.trim())
.filter(Boolean)
const destination = hops.at(-1) ?? jumpHost
const chain = hops.slice(0, -1)
return [
'-W',
`${host}:${port}`,
...(chain.length > 0 ? ['-J', chain.join(',')] : []),
'--',
destination
]
}
export function spawnProxyCommand(
proxy: EffectiveProxy,
host: string,
port: number,
user: string
): { process: ChildProcess; sock: NetSocket } {
const proc =
proxy.kind === 'jump-host'
? // Why: ProxyJump is structured input, not a shell snippet. Spawn ssh
// directly so jump-host values cannot escape through shell parsing.
spawnProcess({
program: 'ssh',
args: jumpHostSpawnArgs(proxy.jumpHost, host, port)
})
: (() => {
const escape = process.platform === 'win32' ? cmdEscape : shellEscape
const expanded = proxy.command
.replace(/%h/g, escape(host))
.replace(/%p/g, escape(String(port)))
.replace(/%r/g, escape(user))
const shell = getShellSpawnConfig(expanded)
// Why not spawnProcess here: a ProxyCommand is a user-authored shell
// snippet, so it keeps its own verbatim command line. The console
// still has to be hidden -- a cmd.exe spawn from a GUI process always
// flashes and steals foreground otherwise (#10488).
return spawn(shell.file, shell.args, {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
windowsVerbatimArguments: shell.windowsVerbatimArguments
})
})()
// Why: a single PassThrough for both directions creates a feedback loop.
// Reads come from the proxy's stdout; writes go to its stdin.
let cleanedUp = false
const cleanup = (): void => {
if (cleanedUp) {
return
}
cleanedUp = true
proc.stdout!.off('data', onStdoutData)
proc.stdout!.off('end', onStdoutEnd)
proc.stderr!.off('data', onStderrData)
proc.stdin!.off('error', onInputError)
proc.off('error', onProcessError)
}
const onStdoutData = (data: Buffer): void => {
// Why: honour the Duplex's backpressure so a slow ssh2 consumer cannot
// buffer the proxy's output unboundedly.
if (!stream.push(data)) {
proc.stdout!.pause()
}
}
// Why: an undrained stderr pipe fills and blocks the proxy process, which
// looks like a silently hung connection.
const onStderrData = (data: Buffer): void => {
const text = data.toString('utf-8').trimEnd()
if (text) {
console.error(`[ssh-proxy-command] ${text}`)
}
}
const onStdoutEnd = (): void => {
stream.push(null)
}
const onInputError = (err: Error): void => {
stream.destroy(err)
}
const onProcessError = (err: Error): void => {
stream.destroy(err)
}
const stream = new Duplex({
read() {
proc.stdout!.resume()
},
write(chunk, _encoding, cb) {
proc.stdin!.write(chunk, cb)
},
destroy(err, cb) {
cleanup()
cb(err)
}
})
proc.stdout!.on('data', onStdoutData)
proc.stdout!.on('end', onStdoutEnd)
proc.stderr!.on('data', onStderrData)
proc.stdin!.on('error', onInputError)
proc.on('error', onProcessError)
return { process: proc, sock: stream as unknown as NetSocket }
}