Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import type { Host } from "./models.ts";
|
|
import { serializeHostsToSshConfig } from "./sshConfigSerializer.ts";
|
|
import { importVaultHostsFromText } from "./vaultImport.ts";
|
|
|
|
const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
|
id: "host-1",
|
|
label: "X11 Host",
|
|
hostname: "x11.example.com",
|
|
username: "root",
|
|
port: 22,
|
|
protocol: "ssh",
|
|
os: "linux",
|
|
tags: [],
|
|
...overrides,
|
|
});
|
|
|
|
test("serializeHostsToSshConfig writes ForwardX11 for hosts with X11 forwarding enabled", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: true })]);
|
|
|
|
assert.match(config, /ForwardX11 yes/);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig omits ForwardX11 when X11 forwarding is disabled", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ x11Forwarding: false })]);
|
|
|
|
assert.doesNotMatch(config, /ForwardX11/);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig omits ForwardX11 for mosh hosts", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ moshEnabled: true, x11Forwarding: true })]);
|
|
|
|
assert.doesNotMatch(config, /ForwardX11/);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig preserves system agent authentication directives", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({
|
|
identityFilePaths: ["~/.ssh/aws_root"],
|
|
useSshAgent: true,
|
|
identityAgent: "$SSH_AUTH_SOCK",
|
|
identitiesOnly: true,
|
|
addKeysToAgent: "yes",
|
|
useKeychain: true,
|
|
})]);
|
|
|
|
assert.match(config, /IdentityFile ~\/\.ssh\/aws_root/);
|
|
assert.match(config, /IdentityAgent \$SSH_AUTH_SOCK/);
|
|
assert.match(config, /IdentitiesOnly yes/);
|
|
assert.match(config, /AddKeysToAgent yes/);
|
|
assert.match(config, /UseKeychain yes/);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig preserves a disabled imported agent setting", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({
|
|
identityFilePaths: ["~/.ssh/aws_root"],
|
|
useSshAgent: false,
|
|
addKeysToAgent: "yes",
|
|
useKeychain: true,
|
|
})]);
|
|
const imported = importVaultHostsFromText("ssh_config", config);
|
|
|
|
assert.match(config, /IdentityAgent none/);
|
|
assert.notEqual(imported.hosts[0]?.useSshAgent, true);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig preserves a plain explicit agent opt-out", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ useSshAgent: false })]);
|
|
const imported = importVaultHostsFromText("ssh_config", config);
|
|
|
|
assert.match(config, /IdentityAgent none/);
|
|
assert.equal(imported.hosts[0]?.useSshAgent, false);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig preserves an enabled default agent setting", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ useSshAgent: true })]);
|
|
const imported = importVaultHostsFromText("ssh_config", config);
|
|
|
|
assert.match(config, /IdentityAgent \$\{SSH_AUTH_SOCK\}/);
|
|
assert.equal(imported.hosts[0]?.useSshAgent, true);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig rejects line injection in serialized fields", () => {
|
|
const maliciousValues: Partial<Host>[] = [
|
|
{ username: "root\nProxyCommand /tmp/run" },
|
|
{ identityFilePaths: ["~/.ssh/id\rProxyCommand /tmp/run"] },
|
|
{ hostname: "host.example.com\0ProxyCommand /tmp/run" },
|
|
];
|
|
|
|
for (const overrides of maliciousValues) {
|
|
assert.throws(
|
|
() => serializeHostsToSshConfig([makeHost(overrides)]),
|
|
/line breaks or null bytes/i,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("serializeHostsToSshConfig encodes Host pattern characters as literal aliases", () => {
|
|
const encoded = serializeHostsToSshConfig([makeHost({ label: "prod*" })]);
|
|
const literal = serializeHostsToSshConfig([makeHost({ label: "prod-2a-" })]);
|
|
const leadingDash = serializeHostsToSshConfig([makeHost({ label: "-jump" })]);
|
|
const quoted = serializeHostsToSshConfig([makeHost({ label: 'bad"alias' })]);
|
|
const escaped = serializeHostsToSshConfig([makeHost({ label: 'bad\\alias' })]);
|
|
|
|
assert.match(encoded, /^Host netcatty-encoded-/m);
|
|
assert.match(literal, /^Host prod-2a-$/m);
|
|
assert.notEqual(encoded.match(/^Host (.+)$/m)?.[1], literal.match(/^Host (.+)$/m)?.[1]);
|
|
assert.match(leadingDash, /^Host netcatty-encoded-/m);
|
|
assert.doesNotMatch(leadingDash, /^Host -/m);
|
|
assert.match(quoted, /^Host netcatty-encoded-/m);
|
|
assert.doesNotMatch(quoted, /^Host .*"/m);
|
|
assert.match(escaped, /^Host netcatty-encoded-/m);
|
|
assert.doesNotMatch(escaped, /^Host .*\\/m);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig quotes usernames containing spaces", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({ username: "alice smith" })]);
|
|
|
|
assert.match(config, /^ {4}User "alice smith"$/m);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig escapes quoted SSH arguments", () => {
|
|
const config = serializeHostsToSshConfig([makeHost({
|
|
hostname: 'bad"host',
|
|
username: 'alice"ops',
|
|
identityFilePaths: ['~/.ssh/id"quoted', '~/.ssh/id\\backslash'],
|
|
identityAgent: '/tmp/agent"socket',
|
|
})]);
|
|
|
|
assert.match(config, /^ {4}HostName "bad\\"host"$/m);
|
|
assert.match(config, /^ {4}User "alice\\"ops"$/m);
|
|
assert.match(config, /^ {4}IdentityFile "~\/\.ssh\/id\\"quoted"$/m);
|
|
assert.match(config, /^ {4}IdentityFile "~\/\.ssh\/id\\\\backslash"$/m);
|
|
assert.match(config, /^ {4}IdentityAgent "\/tmp\/agent\\"socket"$/m);
|
|
});
|
|
|
|
test("serializeHostsToSshConfig rejects ProxyJump separator injection", () => {
|
|
const target = makeHost({
|
|
id: "target",
|
|
hostChain: { hostIds: ["jump"] },
|
|
});
|
|
const jump = makeHost({
|
|
id: "jump",
|
|
hostname: "legit.example,attacker.example",
|
|
});
|
|
const badUsernameJump = makeHost({
|
|
id: "jump",
|
|
hostname: "jump.example.com",
|
|
username: "root,attacker",
|
|
});
|
|
const emailUsernameJump = makeHost({
|
|
id: "jump",
|
|
hostname: "jump.example.com",
|
|
username: "alice@example.com",
|
|
});
|
|
const optionLikeJump = makeHost({
|
|
id: "jump",
|
|
hostname: "-oProxyCommand=run",
|
|
username: "",
|
|
});
|
|
|
|
assert.throws(
|
|
() => serializeHostsToSshConfig([target], [target, jump]),
|
|
/ProxyJump separator/i,
|
|
);
|
|
assert.throws(
|
|
() => serializeHostsToSshConfig([target], [target, badUsernameJump]),
|
|
/ProxyJump separator/i,
|
|
);
|
|
assert.match(
|
|
serializeHostsToSshConfig([target], [target, emailUsernameJump]),
|
|
/ProxyJump alice@example\.com@jump\.example\.com/,
|
|
);
|
|
assert.throws(
|
|
() => serializeHostsToSshConfig([target], [target, optionLikeJump]),
|
|
/ProxyJump separator/i,
|
|
);
|
|
});
|