From e83ff72b61c760f74bc1c596655b60c63153cf2c Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 13 Jun 2026 10:37:39 -0400 Subject: [PATCH] COD-107 fix: shellescape -J jumpHost + structural validator (close command-injection) buildSshConnectionArgs interpolated jumpHost raw while its siblings (identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned" executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces through. - shellescape jumpHost in buildSshConnectionArgs (primary fix) - replace jumpHost denylist with a structural allowlist: [user@]host[:port], comma-separated multi-hop, bracketed IPv6; no shell metachar can appear - update/extend tests: escaped -J assertion + injection-safety case Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean, regex accepts valid forms / rejects 8 injection payloads. Co-Authored-By: Claude Opus 4.8 --- src/remote-hosts.ts | 4 ++-- src/web/schemas.ts | 12 +++++++++--- test/remote-ssh-options.test.ts | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index eb1b1c3d..f7525af9 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -105,7 +105,7 @@ function expandIdentityPath(identityFile: string): string { * ssh -o BatchMode=yes * [-p ] * [-i ] (~/$HOME expanded, then shellescaped) - * [-J ] + * [-J ] (shellescaped, single token) * [-o ProxyCommand=nc -X 5 -x %h %p] (ONE shellescaped -o token) * [-o ] … (each extra option, shellescaped) * @@ -120,7 +120,7 @@ export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick { + it('supports an explicit -J jump host (shellescaped, like its siblings)', () => { const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'bastion@10.0.0.1:22' }); - expect(args.join(' ')).toContain('-J bastion@10.0.0.1:22'); + expect(args.join(' ')).toContain("-J 'bastion@10.0.0.1:22'"); + }); + + it('shellescapes a -J jump host containing shell metacharacters (no injection)', () => { + // Defense-in-depth: even if a metachar-laden value slipped past schema validation, + // it must stay a single shell token and never break out of the ssh command. + const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'x; touch /tmp/pwned' }); + const joined = args.join(' '); + // The whole value is wrapped in single quotes — the `;` cannot start a new command. + expect(joined).toContain("-J 'x; touch /tmp/pwned'"); + expect(joined).not.toContain('-J x;'); }); it('expands a $HOME-prefixed identity path', () => {