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', () => {