Skip to content
Open
6 changes: 6 additions & 0 deletions .changeset/sandbox-ssh-host-key-pinning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bunny.net/sandbox": patch
"@bunny.net/cli": patch
---

fix(sandbox): verify a sandbox's SSH host key before sending a token, pinning it in a known-hosts store to prevent credential disclosure to an impersonating server
35 changes: 35 additions & 0 deletions packages/cli/src/commands/sandbox/ssh-exec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { sandboxKnownHostsPath } from "@bunny.net/sandbox/known-hosts";
import type { SandboxRecord } from "../../config/schema.ts";
import { sshArgs } from "./ssh-exec.ts";

const record: SandboxRecord = {
app_id: "app-1",
agent_token: "secret-token",
ssh_host: "sandbox.example.net:8023",
};

describe("sshArgs host-key verification", () => {
const args = sshArgs(record, "uname -a");
const joined = args.join(" ");

test("does not disable host-key checking", () => {
expect(joined).not.toContain("StrictHostKeyChecking=no");
expect(joined).not.toContain("UserKnownHostsFile=/dev/null");
});

test("uses trust-on-first-use against the shared known-hosts file", () => {
expect(joined).toContain("StrictHostKeyChecking=accept-new");
expect(args).toContain(`UserKnownHostsFile="${sandboxKnownHostsPath()}"`);
});

test("keeps entries plaintext so the SDK can parse the shared file", () => {
expect(args).toContain("HashKnownHosts=no");
});

test("still targets the right host and port and never embeds the token", () => {
expect(args).toContain("8023");
expect(args).toContain("root@sandbox.example.net");
expect(joined).not.toContain("secret-token");
});
});
15 changes: 11 additions & 4 deletions packages/cli/src/commands/sandbox/ssh-exec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmod, mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { sandboxKnownHostsPath } from "@bunny.net/sandbox/known-hosts";
import type { SandboxRecord } from "../../config/schema.ts";

export const WORKPLACE = "/workplace";
Expand Down Expand Up @@ -36,10 +37,14 @@ export function sshArgs(
...(options.tty ? ["-t"] : []),
"-p",
portStr,
// Trust the host key on first contact, in the dedicated file the SDK also uses.
"-o",
"StrictHostKeyChecking=no",
"StrictHostKeyChecking=accept-new",
"-o",
"UserKnownHostsFile=/dev/null",
`UserKnownHostsFile="${sandboxKnownHostsPath()}"`,
// Plaintext entries so the SDK's parser can read the shared file.
"-o",
"HashKnownHosts=no",
"-o",
"LogLevel=ERROR",
`root@${host}`,
Expand All @@ -59,6 +64,8 @@ export async function withSshEnv<T>(
record: SandboxRecord,
fn: (env: Record<string, string>) => Promise<T>,
): Promise<T> {
// ssh writes the known-hosts file but won't create its parent directory.
await mkdir(dirname(sandboxKnownHostsPath()), { recursive: true });
const dir = await mkdtemp(join(tmpdir(), "bunny-ssh-"));
const scriptPath = join(dir, "askpass");
await Bun.write(scriptPath, `#!/bin/sh\nprintf '%s' "$BUNNY_SSH_TOKEN"\n`);
Expand Down
4 changes: 4 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./known-hosts": {
"types": "./dist/known-hosts.d.ts",
"import": "./dist/known-hosts.js"
}
},
"files": [
Expand Down
4 changes: 4 additions & 0 deletions packages/sandbox/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export class SandboxError extends Error {
}
}

export class HostKeyVerificationError extends SandboxError {
override name = "HostKeyVerificationError";
}

/** Thrown when a command exceeds its timeout. Carries the output collected so far. */
export class CommandTimeoutError extends SandboxError {
constructor(
Expand Down
6 changes: 5 additions & 1 deletion packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export { Command, CommandFinished, type LogChunk } from "./command.ts";
export { CommandTimeoutError, SandboxError } from "./errors.ts";
export {
CommandTimeoutError,
HostKeyVerificationError,
SandboxError,
} from "./errors.ts";
export { Sandbox } from "./sandbox.ts";
export type {
BlockingCommandOptions,
Expand Down
158 changes: 158 additions & 0 deletions packages/sandbox/src/known-hosts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeKnownHost, verifyKnownHost } from "./known-hosts.ts";

const dirs: string[] = [];
function storePath(): string {
const dir = mkdtempSync(join(tmpdir(), "bunny-known-hosts-"));
dirs.push(dir);
return join(dir, "nested", "sandbox_known_hosts");
}

/** Build a minimal SSH public-key blob: length-prefixed algorithm + body. */
function hostKey(type: string, body: string): Buffer {
const name = Buffer.from(type, "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(name.length, 0);
return Buffer.concat([len, name, Buffer.from(body)]);
}

const keyA = hostKey("ssh-ed25519", "AAAA-key-a");
const keyB = hostKey("ssh-ed25519", "BBBB-key-b");

afterEach(() => {
for (const dir of dirs.splice(0))
rmSync(dir, { recursive: true, force: true });
});

describe("verifyKnownHost trust-on-first-use", () => {
test("trusts the first key and records an OpenSSH-format line", () => {
const path = storePath();
expect(verifyKnownHost("host", 8023, keyA, path)).toBe(true);
expect(readFileSync(path, "utf8").trim()).toBe(
`[host]:8023 ssh-ed25519 ${keyA.toString("base64")}`,
);
});

test("accepts the same key on a later connection", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path);
expect(verifyKnownHost("host", 8023, keyA, path)).toBe(true);
});

test("rejects a changed key for a known host (impersonation / rotation)", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path);
expect(verifyKnownHost("host", 8023, keyB, path)).toBe(false);
});

test("rejects a new key type for a known host (algorithm downgrade)", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path);
const rsaKey = hostKey("ssh-rsa", "CCCC-key-c");
expect(verifyKnownHost("host", 8023, rsaKey, path)).toBe(false);
expect(readFileSync(path, "utf8")).not.toContain("ssh-rsa");
expect(verifyKnownHost("host", 8023, keyA, path)).toBe(true);
});

test("tracks each host independently", () => {
const path = storePath();
verifyKnownHost("host-a", 8023, keyA, path);
expect(verifyKnownHost("host-b", 8023, keyB, path)).toBe(true);
expect(verifyKnownHost("host-a", 8023, keyA, path)).toBe(true);
});

test("honors a key already pinned by OpenSSH in the shared file", () => {
const path = storePath();
// A line OpenSSH would have written to the shared file.
verifyKnownHost("host", 8023, keyA, path); // creates the dir
writeFileSync(path, `[host]:8023 ssh-ed25519 ${keyA.toString("base64")}\n`);
expect(verifyKnownHost("host", 8023, keyA, path)).toBe(true);
expect(verifyKnownHost("host", 8023, keyB, path)).toBe(false);
});

test("accepts a pin that follows a stale mismatched line", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyB, path);
writeFileSync(
path,
`[host]:8023 ssh-ed25519 ${keyA.toString("base64")}\n` +
`[host]:8023 ssh-ed25519 ${keyB.toString("base64")}\n`,
);
expect(verifyKnownHost("host", 8023, keyB, path)).toBe(true);
expect(verifyKnownHost("host", 8023, keyA, path)).toBe(true);
const other = hostKey("ssh-ed25519", "DDDD-key-d");
expect(verifyKnownHost("host", 8023, other, path)).toBe(false);
});

test("uses a bare hostname (no brackets) for the default SSH port", () => {
const path = storePath();
verifyKnownHost("host", 22, keyA, path);
expect(readFileSync(path, "utf8")).toContain("host ssh-ed25519");
});

test("rejects a malformed key blob", () => {
const path = storePath();
expect(verifyKnownHost("host", 8023, Buffer.from([1, 2]), path)).toBe(
false,
);
});
});

describe("removeKnownHost", () => {
test("drops a host's pin so the next connection re-pins it", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path);
removeKnownHost("host", 8023, path);
expect(readFileSync(path, "utf8")).not.toContain("host");
expect(verifyKnownHost("host", 8023, keyB, path)).toBe(true);
});

test("only removes the targeted host and port", () => {
const path = storePath();
verifyKnownHost("host-a", 8023, keyA, path);
verifyKnownHost("host-b", 8023, keyB, path);
removeKnownHost("host-a", 8023, path);
const text = readFileSync(path, "utf8");
expect(text).not.toContain("[host-a]:8023");
expect(text).toContain("[host-b]:8023");
expect(verifyKnownHost("host-b", 8023, keyB, path)).toBe(true);
});

test("removes every entry recorded for the host", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path);
const rsaKey = hostKey("ssh-rsa", "CCCC-key-c");
writeFileSync(
path,
`[host]:8023 ssh-ed25519 ${keyA.toString("base64")}\n` +
`[host]:8023 ssh-rsa ${rsaKey.toString("base64")}\n`,
);
removeKnownHost("host", 8023, path);
expect(readFileSync(path, "utf8").trim()).toBe("");
});

test("keeps other aliases sharing a comma-separated line", () => {
const path = storePath();
verifyKnownHost("host", 8023, keyA, path); // creates the dir
writeFileSync(
path,
`[host]:8023,[alias]:8023 ssh-ed25519 ${keyA.toString("base64")}\n`,
);
removeKnownHost("host", 8023, path);
const text = readFileSync(path, "utf8");
expect(text).not.toContain("[host]:8023");
expect(text).toBe(`[alias]:8023 ssh-ed25519 ${keyA.toString("base64")}\n`);
expect(verifyKnownHost("alias", 8023, keyA, path)).toBe(true);
});

test("is a no-op when the host is absent or the file is missing", () => {
const path = storePath();
expect(() => removeKnownHost("host", 8023, path)).not.toThrow();
verifyKnownHost("host", 8023, keyA, path);
removeKnownHost("other", 8023, path);
expect(readFileSync(path, "utf8")).toContain("[host]:8023");
});
});
Loading