Files
Netcatty/electron/bridges/sshAuthHelper.kbdInteractive.test.cjs
陈大猫 344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00

271 lines
11 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const {
createKeyboardInteractiveHandler,
isAutoFillablePasswordChallenge,
} = require("./sshAuthHelper.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const createSender = () => {
const sent = [];
return {
sent,
sender: {
id: 42,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
};
};
// Settles any modal requests that the handler queued via storeRequest so the
// 5-minute TTL timer doesn't keep the test process alive.
const drainPendingRequests = (sent) => {
for (const event of sent) {
if (event.channel !== "netcatty:keyboard-interactive") continue;
const requestId = event.payload?.requestId;
if (requestId) {
keyboardInteractiveHandler.handleResponse(null, { requestId, cancelled: true });
}
}
};
const passwordPrompt = { prompt: "Password:", echo: false };
const linuxPasswordPrompt = { prompt: "[sudo] password for alice:", echo: false };
const verificationCodePrompt = { prompt: "Verification code:", echo: true };
const otpPrompt = { prompt: "Verification code:", echo: false }; // Google Auth / TOTP
const duoPrompt = { prompt: "Duo two-factor login\nPasscode or option (1-1):", echo: false };
const cjkPasswordPrompt = { prompt: "密码:", echo: false };
const customizedAuthPrompt = { prompt: "Please authenticate:", echo: false };
// OTP prompts that DO mention the word "password" or "口令" — the literal
// keyword should not be enough to trigger auto-fill (#969 PR review round 2).
const oneTimePasswordPrompt = { prompt: "Enter your one-time password:", echo: false };
const cjkDynamicPasswordPrompt = { prompt: "动态密码:", echo: false };
const cjkDynamicTokenPrompt = { prompt: "动态口令:", echo: false };
const cjkOneTimePasswordPrompt = { prompt: "一次性密码:", echo: false };
// --- isAutoFillablePasswordChallenge ---------------------------------------
test("isAutoFillablePasswordChallenge accepts a single hidden-echo prompt with a saved password", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge rejects multi-prompt challenges (likely 2FA)", () => {
assert.equal(
isAutoFillablePasswordChallenge([passwordPrompt, verificationCodePrompt], "hunter2"),
false,
);
});
test("isAutoFillablePasswordChallenge rejects echo=true prompts (could be username / OTP)", () => {
assert.equal(isAutoFillablePasswordChallenge([verificationCodePrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects when no saved password is available", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], ""), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], undefined), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], null), false);
});
test("isAutoFillablePasswordChallenge rejects empty / non-array prompts", () => {
assert.equal(isAutoFillablePasswordChallenge([], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge(undefined, "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects OTP-style hidden prompts (Google Authenticator, TOTP)", () => {
// Single prompt, echo=false, but the text says "Verification code" — that's
// a 2FA challenge, not a password. Submitting the saved password here would
// burn an auth attempt on the server. (#969 PR review)
assert.equal(isAutoFillablePasswordChallenge([otpPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Duo-style passcode prompts", () => {
// "Passcode" is the term Duo uses for the OTP, not a reusable password.
// Treat it as a 2FA challenge.
assert.equal(isAutoFillablePasswordChallenge([duoPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts CJK password prompts", () => {
// PAM on Chinese-locale Linux often renders "密码:" — the user still
// expects the saved password to work.
assert.equal(isAutoFillablePasswordChallenge([cjkPasswordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge falls through to the modal for unrecognized prompt text", () => {
// Custom prompts that don't mention a known keyword stay on the safe side
// — the user sees the modal as before. No regression from the old
// always-prompt baseline.
assert.equal(isAutoFillablePasswordChallenge([customizedAuthPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects 'One-time password' even though it contains the word 'password'", () => {
// PR review round 2: the OTP vocabulary check must run before the password
// keyword check, otherwise "password" in "One-time password" triggers a
// false-positive auto-fill that burns a 2FA attempt.
assert.equal(isAutoFillablePasswordChallenge([oneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Chinese OTP prompts ('动态密码', '动态口令', '一次性密码')", () => {
// The Chinese "动态密码" / "动态口令" / "一次性密码" idioms specifically
// mean OTP. Mustn't auto-fill the reusable password into them.
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicTokenPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkOneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts a sudo-style password prompt", () => {
// Regression guard: the OTP deny-list should not over-block normal Linux
// PAM prompts that legitimately mention a username after "password".
assert.equal(isAutoFillablePasswordChallenge([linuxPasswordPrompt], "hunter2"), true);
});
// --- createKeyboardInteractiveHandler --------------------------------------
test("createKeyboardInteractiveHandler auto-fills the saved password for a single password prompt", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
// The handler answered without sending any IPC and without showing a prompt.
assert.deepEqual(sent, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(finishCalls, [["hunter2"]]);
});
test("createKeyboardInteractiveHandler falls back to the modal on the retry after a failed auto-fill", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "wrong-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
// First call — auto-fill fires, no modal shown.
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ first: responses }));
// ssh2 re-invokes after auth failure — this time the user must see the modal.
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ second: responses }));
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.deepEqual(finishCalls, [{ first: ["wrong-password"] }]);
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal when the challenge is real 2FA (multiple prompts)", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.prompts.length, 2);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not auto-fill when no saved password is configured", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: undefined,
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("", "", "", [passwordPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal for OTP-style hidden prompts even with a saved password", () => {
// Regression guard for the #969 PR review: a single hidden-echo prompt
// that doesn't mention "password" must not auto-submit the saved value.
const { sender, sent } = createSender();
const autoFillEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
handler("", "", "", [otpPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.equal(sent.length, 1, "modal IPC should fire instead of auto-fill");
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler short-circuits when the server sends zero prompts", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [], (responses) => finishCalls.push(responses));
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(sent, []);
assert.deepEqual(finishCalls, [[]]);
});