mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
b7325218fb
* feat(ssh): accept PuTTY-style CLI args from bastion hosts Reuse the existing ssh:// and telnet:// deep-link path so PAM products that launch putty.exe -ssh user@host -P port -pw password can point at Netcatty instead. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): consume operands of ignored PuTTY flags Unknown value-taking options such as -m and -D were skipped while their next token was treated as a host or port, which could connect the supplied password to the wrong endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): reject unsupported PuTTY protocols and unknown flags -raw/-rlogin/-serial must not fall through to SSH with the supplied password, and valueless switches such as -batch must not consume the following host. Unknown dash flags are now rejected instead of guessed. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): reject missing or empty PuTTY option operands An empty or trailing -pw was turned into ssh://user:@host, which the deep-link path treats as no password and can then authenticate with a saved vault credential. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): reject unsupported -pwfile, -i, and -hostkey launches Those options request credentials or a host-key pin this pipeline cannot honor, so connecting with -pw or a vault match would silently change the authentication the caller asked for. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): keep .app/.exe hosts and reject -loghost Suffix filtering now only covers the Electron script argument, so destinations like prod.app are not dropped. -loghost is rejected because this pipeline cannot honor the logical host-key identity. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ssh): keep packaged argv[1] hosts like prod.js Suffix filtering now applies only after an Electron binary, so a packaged launch can still use a first-position hostname that looks like a script. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
7.9 KiB
JavaScript
328 lines
7.9 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const {
|
|
parsePuttyCommandLine,
|
|
redactPuttyCommandLinePasswords,
|
|
} = require("./puttyCommandLine.cjs");
|
|
|
|
test("parsePuttyCommandLine accepts JumpServer PuTTY template", () => {
|
|
assert.deepEqual(
|
|
parsePuttyCommandLine([
|
|
String.raw`C:\Program Files\Netcatty\Netcatty.exe`,
|
|
"-ssh",
|
|
"alice@10.0.0.8",
|
|
"-P",
|
|
"2222",
|
|
"-pw",
|
|
"s3cret",
|
|
]),
|
|
{
|
|
protocol: "ssh",
|
|
url: "ssh://alice:s3cret@10.0.0.8:2222",
|
|
hostname: "10.0.0.8",
|
|
username: "alice",
|
|
password: "s3cret",
|
|
port: 2222,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("parsePuttyCommandLine accepts -l username and mixed flag order", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"bastion.example.com",
|
|
"-P",
|
|
"22",
|
|
"-l",
|
|
"ops",
|
|
"-pw",
|
|
"hunter2",
|
|
"-ssh",
|
|
]);
|
|
assert.equal(parsed?.url, "ssh://ops:hunter2@bastion.example.com:22");
|
|
assert.equal(parsed?.username, "ops");
|
|
});
|
|
|
|
test("parsePuttyCommandLine prefers user@host over -newtab session names", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-newtab",
|
|
"Production",
|
|
"-ssh",
|
|
"root@192.168.10.2",
|
|
"-P",
|
|
"22",
|
|
"-pw",
|
|
"one-time",
|
|
]);
|
|
assert.equal(parsed?.hostname, "192.168.10.2");
|
|
assert.equal(parsed?.username, "root");
|
|
assert.equal(parsed?.url, "ssh://root:one-time@192.168.10.2:22");
|
|
});
|
|
|
|
test("parsePuttyCommandLine percent-encodes passwords for ssh:// round-trip", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@example.com",
|
|
"-pw",
|
|
"p@ss:w/rd",
|
|
]);
|
|
assert.equal(parsed?.url, "ssh://alice:p%40ss%3Aw%2Frd@example.com");
|
|
assert.equal(parsed?.password, "p@ss:w/rd");
|
|
});
|
|
|
|
test("parsePuttyCommandLine accepts IPv6 hosts and optional positional port", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"electron",
|
|
".",
|
|
"-ssh",
|
|
"bob@[2001:db8::10]",
|
|
"2200",
|
|
]);
|
|
assert.equal(parsed?.hostname, "2001:db8::10");
|
|
assert.equal(parsed?.port, 2200);
|
|
assert.equal(parsed?.url, "ssh://bob@[2001:db8::10]:2200");
|
|
});
|
|
|
|
test("parsePuttyCommandLine accepts user@host:port without -P", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@bastion.example.com:2222",
|
|
"-pw",
|
|
"token",
|
|
]);
|
|
assert.equal(parsed?.url, "ssh://alice:token@bastion.example.com:2222");
|
|
});
|
|
|
|
test("parsePuttyCommandLine accepts telnet launches", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-telnet",
|
|
"router.lab",
|
|
"-P",
|
|
"23",
|
|
]);
|
|
assert.equal(parsed?.protocol, "telnet");
|
|
assert.equal(parsed?.url, "telnet://router.lab:23");
|
|
});
|
|
|
|
test("parsePuttyCommandLine ignores Chromium flags and missing hosts", () => {
|
|
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "--enable-logging", "-ssh"]), null);
|
|
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "just-a-file.txt"]), null);
|
|
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-P", "22"]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine rejects invalid ports", () => {
|
|
assert.equal(parsePuttyCommandLine(["Netcatty.exe", "-ssh", "host.example", "-P", "99999"]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine does not treat -m operands as the destination host", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"-l",
|
|
"alice",
|
|
"server.example",
|
|
"-m",
|
|
"commands.txt",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(parsed?.hostname, "server.example");
|
|
assert.equal(parsed?.username, "alice");
|
|
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
|
});
|
|
|
|
test("parsePuttyCommandLine does not treat -D operands as the destination port", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-D",
|
|
"1080",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(parsed?.hostname, "server.example");
|
|
assert.equal(parsed?.port, undefined);
|
|
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
|
});
|
|
|
|
test("parsePuttyCommandLine consumes operands of known value-taking flags such as -cmd", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-cmd",
|
|
"whoami",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(parsed?.hostname, "server.example");
|
|
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
|
});
|
|
|
|
test("parsePuttyCommandLine rejects unsupported PuTTY protocol selectors", () => {
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"server.example",
|
|
"-raw",
|
|
"-P",
|
|
"8000",
|
|
"-pw",
|
|
"secret",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-serial",
|
|
"COM1",
|
|
"-P",
|
|
"22",
|
|
]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine keeps the host after valueless options such as -batch", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"-batch",
|
|
"alice@server.example",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(parsed?.url, "ssh://alice:secret@server.example");
|
|
});
|
|
|
|
test("parsePuttyCommandLine rejects unknown dash flags", () => {
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"-not-a-putty-flag",
|
|
"alice@server.example",
|
|
]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine rejects missing or empty option operands", () => {
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-pw",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-pw",
|
|
"",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-P",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"server.example",
|
|
"-l",
|
|
"",
|
|
"-pw",
|
|
"secret",
|
|
]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine rejects unsupported authentication and host-key overrides", () => {
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-pwfile",
|
|
"secret.txt",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-i",
|
|
"id_rsa.ppk",
|
|
"-pw",
|
|
"secret",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-hostkey",
|
|
"aa:bb:cc:dd",
|
|
"-pw",
|
|
"secret",
|
|
]), null);
|
|
assert.equal(parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@server.example",
|
|
"-loghost",
|
|
"logical.example",
|
|
"-pw",
|
|
"secret",
|
|
]), null);
|
|
});
|
|
|
|
test("parsePuttyCommandLine accepts destinations with executable-like suffixes", () => {
|
|
const parsed = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"-ssh",
|
|
"alice@prod.app",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(parsed?.hostname, "prod.app");
|
|
assert.equal(parsed?.url, "ssh://alice:secret@prod.app");
|
|
|
|
const positional = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"gateway.exe",
|
|
"-ssh",
|
|
"-l",
|
|
"alice",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(positional?.hostname, "gateway.exe");
|
|
assert.equal(positional?.url, "ssh://alice:secret@gateway.exe");
|
|
|
|
const scriptLikeHost = parsePuttyCommandLine([
|
|
"Netcatty.exe",
|
|
"prod.js",
|
|
"-ssh",
|
|
"-l",
|
|
"alice",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(scriptLikeHost?.hostname, "prod.js");
|
|
assert.equal(scriptLikeHost?.url, "ssh://alice:secret@prod.js");
|
|
|
|
const fromElectron = parsePuttyCommandLine([
|
|
"electron",
|
|
"main.cjs",
|
|
"-ssh",
|
|
"alice@host.example",
|
|
"-pw",
|
|
"secret",
|
|
]);
|
|
assert.equal(fromElectron?.hostname, "host.example");
|
|
assert.equal(fromElectron?.url, "ssh://alice:secret@host.example");
|
|
});
|
|
|
|
test("redactPuttyCommandLinePasswords masks -pw values in argv", () => {
|
|
const argv = ["Netcatty.exe", "-ssh", "alice@host", "-pw", "s3cret!!"];
|
|
redactPuttyCommandLinePasswords(argv);
|
|
assert.equal(argv[4], "********");
|
|
});
|