mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
268a0bbdbd
commit
e83ff72b61
+2
-2
@@ -105,7 +105,7 @@ function expandIdentityPath(identityFile: string): string {
|
||||
* ssh -o BatchMode=yes
|
||||
* [-p <port>]
|
||||
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
|
||||
* [-J <jumpHost>]
|
||||
* [-J <jumpHost>] (shellescaped, single token)
|
||||
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
|
||||
* [-o <KEY=VALUE>] … (each extra option, shellescaped)
|
||||
*
|
||||
@@ -120,7 +120,7 @@ export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHos
|
||||
const parts: string[] = ['ssh', '-o BatchMode=yes'];
|
||||
if (remote.port) parts.push(`-p ${remote.port}`);
|
||||
if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`);
|
||||
if (remote.jumpHost) parts.push(`-J ${remote.jumpHost}`);
|
||||
if (remote.jumpHost) parts.push(`-J ${shellescape(remote.jumpHost)}`);
|
||||
if (remote.socksProxy) {
|
||||
parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`);
|
||||
}
|
||||
|
||||
+9
-3
@@ -315,13 +315,19 @@ export const RemoteHostSchema = z.object({
|
||||
.string()
|
||||
.regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port')
|
||||
.optional(),
|
||||
// SSH jump host ([user@]host[:port]); reject shell metacharacters.
|
||||
// SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural
|
||||
// ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6,
|
||||
// so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value
|
||||
// is also shellescaped at command-build time (buildSshConnectionArgs); this is the
|
||||
// belt to that suspenders.
|
||||
jumpHost: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid jump host')
|
||||
.refine(noCommandSubstitution, 'Invalid jump host')
|
||||
.regex(
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:\[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:\[\]-]+(?::\d{1,5})?)*$/,
|
||||
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
|
||||
)
|
||||
.optional(),
|
||||
// Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE.
|
||||
extraSshOptions: z
|
||||
|
||||
@@ -87,9 +87,19 @@ describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () =
|
||||
expect(idxProxy).toBeLessThan(idxExtra);
|
||||
});
|
||||
|
||||
it('supports an explicit -J jump host', () => {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user