From 3fe3912365452409189613b6f95a1f71a0aba57e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 10:26:13 +0100 Subject: [PATCH 1/4] fix: verify SSH2 host keys against known_hosts --- src/node/runtime/SSH2ConnectionPool.ts | 89 ++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index d06864acad..e893ffc7b2 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -10,6 +10,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; +import { createHmac } from "crypto"; import { spawn, type ChildProcess } from "child_process"; import { Duplex } from "stream"; import type { Client } from "ssh2"; @@ -138,6 +139,80 @@ const DEFAULT_IDENTITY_FILES = [ "~/.ssh/id_ed25519_sk", "~/.ssh/id_dsa", ]; + +const KNOWN_HOSTS_PATH = path.join(os.homedir(), ".ssh", "known_hosts"); + +function matchesHashedKnownHost(pattern: string, host: string): boolean { + if (!pattern.startsWith("|1|")) { + return false; + } + + const parts = pattern.split("|"); + if (parts.length !== 5) { + return false; + } + + const salt = Buffer.from(parts[3], "base64"); + const expected = parts[4]; + const actual = createHmac("sha1", salt).update(host).digest("base64"); + return actual === expected; +} + +function matchesKnownHostPattern(pattern: string, host: string): boolean { + if (pattern === host) { + return true; + } + + if (pattern.startsWith("!")) { + return false; + } + + return matchesHashedKnownHost(pattern, host); +} + +async function getKnownHostPublicKeys(host: string, port: number): Promise { + const knownHostsEntries = [host, `[${host}]:${port}`]; + + let content: string; + try { + content = await fs.readFile(KNOWN_HOSTS_PATH, "utf8"); + } catch { + return []; + } + + const keys: Buffer[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + const fields = trimmed.split(/\s+/); + const hostFieldIndex = fields[0]?.startsWith("@") ? 1 : 0; + const keyFieldIndex = hostFieldIndex + 2; + const hostPatterns = fields[hostFieldIndex]?.split(","); + const keyBlob = fields[keyFieldIndex]; + if (!hostPatterns?.length || !keyBlob) { + continue; + } + + const matchesHost = hostPatterns.some((pattern) => + knownHostsEntries.some((knownHost) => matchesKnownHostPattern(pattern, knownHost)) + ); + if (!matchesHost) { + continue; + } + + try { + keys.push(Buffer.from(keyBlob, "base64")); + } catch { + // Ignore malformed known_hosts entries. + } + } + + return keys; +} + function expandLocalPath(value: string): string { if (value === "~") { return os.homedir(); @@ -569,6 +644,11 @@ export class SSH2ConnectionPool { cleanupProxy(); }); + const allowedHostKeys = await getKnownHostPublicKeys( + resolvedConfig.hostName, + resolvedConfig.port + ); + await new Promise((resolve, reject) => { const onReady = () => { cleanup(); @@ -607,10 +687,11 @@ export class SSH2ConnectionPool { keepaliveInterval: 5000, keepaliveCountMax: 2, ...(privateKey ? { privateKey } : {}), - // TODO(ethanndickson): Implement known_hosts support for SSH2 - // and restore interactive host key verification once approvals - // can be persisted between connections. - hostVerifier: () => true, + // Enforce known_hosts host key pinning so SSH2 cannot silently + // accept attacker-controlled keys during transport negotiation. + hostVerifier: (presentedKey: Buffer) => + allowedHostKeys.length > 0 && + allowedHostKeys.some((knownHostKey) => knownHostKey.equals(presentedKey)), }; client.connect(connectOptions); From 26e2fc9819dc7f45cadeff1d301ab87234094915 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 10:37:59 +0100 Subject: [PATCH 2/4] fix: enforce SSH2 host key verification via known_hosts --- src/node/runtime/SSH2ConnectionPool.ts | 59 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index e893ffc7b2..8f3f855fc2 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -158,18 +158,67 @@ function matchesHashedKnownHost(pattern: string, host: string): boolean { return actual === expected; } +function wildcardPatternToRegex(pattern: string): RegExp { + let regex = "^"; + for (const char of pattern) { + if (char === "*") { + regex += ".*"; + continue; + } + + if (char === "?") { + regex += "."; + continue; + } + + if (/[\\^$+?.()|{}\[\]]/.test(char)) { + regex += `\\${char}`; + continue; + } + + regex += char; + } + + regex += "$"; + return new RegExp(regex); +} + function matchesKnownHostPattern(pattern: string, host: string): boolean { - if (pattern === host) { + if (pattern === "*" || pattern === host) { return true; } - if (pattern.startsWith("!")) { - return false; + if (pattern.includes("*") || pattern.includes("?")) { + return wildcardPatternToRegex(pattern).test(host); } return matchesHashedKnownHost(pattern, host); } +function hostPatternListMatches(patterns: string[], host: string): boolean { + let hasPositiveMatch = false; + + for (const rawPattern of patterns) { + const isNegated = rawPattern.startsWith("!"); + const pattern = isNegated ? rawPattern.slice(1) : rawPattern; + if (!pattern) { + continue; + } + + if (!matchesKnownHostPattern(pattern, host)) { + continue; + } + + if (isNegated) { + return false; + } + + hasPositiveMatch = true; + } + + return hasPositiveMatch; +} + async function getKnownHostPublicKeys(host: string, port: number): Promise { const knownHostsEntries = [host, `[${host}]:${port}`]; @@ -196,8 +245,8 @@ async function getKnownHostPublicKeys(host: string, port: number): Promise - knownHostsEntries.some((knownHost) => matchesKnownHostPattern(pattern, knownHost)) + const matchesHost = knownHostsEntries.some((knownHost) => + hostPatternListMatches(hostPatterns, knownHost) ); if (!matchesHost) { continue; From c73754ab8f1ced6bddb84c92530fe88b253e4590 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 10:43:55 +0100 Subject: [PATCH 3/4] fix: resolve lint escape warning in SSH2 known_hosts matcher --- src/node/runtime/SSH2ConnectionPool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index 8f3f855fc2..b18e6c89ea 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -158,6 +158,8 @@ function matchesHashedKnownHost(pattern: string, host: string): boolean { return actual === expected; } +const REGEX_SPECIAL_CHARS = new Set(["\\", "^", "$", "+", "?", ".", "(", ")", "|", "{", "}", "[", "]"]); + function wildcardPatternToRegex(pattern: string): RegExp { let regex = "^"; for (const char of pattern) { @@ -171,7 +173,7 @@ function wildcardPatternToRegex(pattern: string): RegExp { continue; } - if (/[\\^$+?.()|{}\[\]]/.test(char)) { + if (REGEX_SPECIAL_CHARS.has(char)) { regex += `\\${char}`; continue; } From d76ea069da113533e7ca901def0772ff5e8b4e17 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 16 Mar 2026 11:34:12 +0100 Subject: [PATCH 4/4] fix: ignore revoked/cert-authority known_hosts markers --- src/node/runtime/SSH2ConnectionPool.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/node/runtime/SSH2ConnectionPool.ts b/src/node/runtime/SSH2ConnectionPool.ts index b18e6c89ea..44a4f9521f 100644 --- a/src/node/runtime/SSH2ConnectionPool.ts +++ b/src/node/runtime/SSH2ConnectionPool.ts @@ -239,7 +239,13 @@ async function getKnownHostPublicKeys(host: string, port: number): Promise