Compare commits

...
6 changed files with 179 additions and 3 deletions

No files matched your search

+1
View File
@@ -553,6 +553,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
hostname: request.hostname,
savedPassword: request.savedPassword,
allowSavePassword: request.allowSavePassword !== false,
defaultSavePassword: request.defaultSavePassword === true,
}]);
});
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
@@ -53,3 +53,20 @@ test("keyboard-interactive modal cannot be dismissed by outside click or Escape"
assert.match(modalSource, /onInteractOutside=\{\(e\) => e\.preventDefault\(\)\}/);
assert.match(modalSource, /onEscapeKeyDown=\{\(e\) => e\.preventDefault\(\)\}/);
});
test("keyboard-interactive modal only saves the pre-check default when the bridge proves the round failed (#3556)", () => {
// The default comes from the bridge (defaultSavePassword) and must still
// require a password slot to save into.
assert.match(modalSource, /setSavePassword\(Boolean\(request\.defaultSavePassword\) && passwordPromptIndex >= 0\)/);
});
test("isAPasswordPrompt recognizes CJK password slots like sshAuthHelper PASSWORD_PROMPT_PATTERN (#3558)", () => {
// The modal must count "密码:" / "口令:" prompts as password slots so
// defaultSavePassword is honored for Chinese-localized PAM challenges.
assert.match(modalSource, /passw\(or\)\?d\|密\\s\*码\|口\\s\*令/);
// CJK second-factor wording must still be excluded (aligned with
// OTP_PROMPT_PATTERN in sshAuthHelper.cjs).
for (const blocked of ["动态", "一次性", "验证码", "令牌", "双因素", "二次", "安全密码", "挑战码"]) {
assert.match(modalSource, new RegExp(`lower\\.includes\\("${blocked}"\\)`));
}
});
+31 -3
View File
@@ -34,6 +34,15 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
allowSavePassword?: boolean;
/**
* Pre-check the "Save password" checkbox — sent only by emitters that have
* an actual failed-auth rejection signal for the round. Same-shape
* keyboard-interactive re-asks carry no rejection signal, so they never
* get it: pre-checking could persist a staged second-factor secret that
* merely reuses the "Password:" label over the host login password
* (#3556/#3558).
*/
defaultSavePassword?: boolean;
}
type KeyboardInteractiveServerPromptInput = Pick<
@@ -69,12 +78,29 @@ export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteracti
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password") && !lower.includes("passwd")) return false;
// Recognize the same password keywords as sshAuthHelper.cjs
// PASSWORD_PROMPT_PATTERN, including CJK "密码" / "口令" so Chinese-localized
// PAM prompts (e.g. "密码:") count as password slots and the
// defaultSavePassword flow works for them (#3558).
if (!/passw(or)?d|密\s*码|口\s*令/i.test(prompt.prompt)) return false;
// Keep aligned with electron/bridges/sshAuthHelper.cjs OTP_PROMPT_PATTERN so
// the modal never prefills the host login password into a second-factor field
// (#2150). Backend also omits savedPassword for those challenges; this is
// defense in depth if a caller still passes it.
// defense in depth if a caller still passes it. CJK entries mirror that
// pattern's CJK blocklist (动态密码 / 一次性密码 / 二次密码 ...).
if (
lower.includes("动态") ||
lower.includes("一次性") ||
lower.includes("验证码") ||
lower.includes("验证信息") ||
lower.includes("令牌") ||
lower.includes("双因素") ||
lower.includes("多因素") ||
lower.includes("短信验证") ||
lower.includes("手机验证") ||
lower.includes("二次") ||
lower.includes("安全密码") ||
lower.includes("挑战码") ||
lower.includes("one-time") ||
lower.includes("otp") ||
lower.includes("verification") ||
@@ -137,7 +163,9 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
// Pre-check "Save password" on the post-failed-auto-fill retry (#3556):
// only meaningful when the challenge has a password slot to save.
setSavePassword(Boolean(request.defaultSavePassword) && passwordPromptIndex >= 0);
}
}, [request, passwordPromptIndex]);
+22
View File
@@ -1972,6 +1972,27 @@ function createKeyboardInteractiveHandler(options) {
);
const allowSavePassword = !(skipAutoFill || singleSecondaryChallenge);
// Codex P1 on #3558 round 3: a later round of an in-flight keyboard-
// interactive exchange carries NO signal telling us whether the server
// *rejected* the auto-filled password or is *chaining* to the next factor
// of a multi-round exchange — ssh2 re-delivers each USERAUTH_INFO_REQUEST
// round straight to this listener without USERAUTH_FAILURE or
// partialSuccess in between. A same-shaped single hidden "Password:"
// re-ask is therefore ambiguous: it can be a stale-password PAM re-ask
// (#3556), but just as well a staged second factor whose EDR/MFA wording
// only appears in the display-only banner while the field label is a bare
// "Password:" (#2150). A same-shape re-ask is not proof of failed
// authentication, so the save checkbox must never be pre-checked by this
// heuristic — the round-2 default would let the secondary secret
// overwrite the host login password on submit.
//
// The checkbox stays available (allowSavePassword above) so a user
// correcting a stale auto-filled password can still tick it manually.
// The field remains part of the payload contract for emitters that
// surface a genuine failed-auth rejection signal (#3556); the renderer
// still gates it on allowSavePassword and a password slot.
const defaultSavePassword = false;
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
try { onPromptShown?.(); } catch (err) { console.warn(`${logPrefix} onPromptShown callback threw`, err); }
@@ -1985,6 +2006,7 @@ function createKeyboardInteractiveHandler(options) {
hostname: hostname,
savedPassword: savedPasswordForModal,
allowSavePassword,
defaultSavePassword,
scope,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
});
@@ -240,6 +240,103 @@ test("createKeyboardInteractiveHandler falls back to the modal on the retry afte
// Do not re-prefill the stale value, but still allow saving a corrected one.
assert.equal(sent[0].payload.savedPassword, null);
assert.equal(sent[0].payload.allowSavePassword, true);
// A same-shape password re-ask is ambiguous — ssh2 sends no rejection
// signal between keyboard-interactive rounds, so it may equally be a
// staged second factor whose wording only appeared in the display-only
// banner (#2150). The save box stays available but unchecked; a
// pre-checked secondary response would overwrite the host login password
// (Codex P1 round 3 on #3558).
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not pre-check save on the first modal", () => {
// No failed auto-fill yet → normal modal, unchecked save box.
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
});
handler("", "", "", [passwordPrompt], () => {});
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, true);
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler keeps defaultSavePassword off for second-factor challenges", () => {
// An EDR secondary / OTP prompt must never get the save default — even a
// retry after a failed auto-fill is a different secret, not the host
// login password (#2150). Round one auto-fills the login password into a
// normal Password: prompt; round two is the EDR secondary prompt.
const { sender, sent } = createSender();
const autoFillEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-edr.example.com",
password: "wrong-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
handler("", "", "", [passwordPrompt], () => {});
handler("", "", "", [edrSecondaryAuthPasswordPrompt], () => {});
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, false);
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler keeps defaultSavePassword off for a chained multi-prompt round", () => {
// A staged chain that re-prompts with Password + OTP in one challenge must
// not get the save default — the OTP slot is a different secret.
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-pam.example.com",
password: "wrong-password",
});
handler("", "", "", [passwordPrompt], () => {}); // auto-fill
handler("", "", "", [passwordPrompt, otpPrompt], () => {}); // chained round
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, true);
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler keeps defaultSavePassword off for password-change shapes", () => {
// Password-expiry / password-change re-asks ask for a different secret
// (old/new), so the save default stays off.
const { sender, sent } = createSender();
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "corp-pam.example.com",
password: "wrong-password",
});
handler("", "", "", [passwordPrompt], () => {}); // auto-fill
handler("", "", "", [newPasswordPrompt], () => {}); // chained change round
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.allowSavePassword, true);
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
@@ -320,6 +417,12 @@ test("createKeyboardInteractiveHandler does not prefill after a prior auto-fill
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
// A same-shape password re-ask carries no rejection signal — it can be a
// staged second factor (wording in the display-only banner) just as much
// as a stale-password retry, so the box must not pre-check. The *prefill*
// also stays off (#2150): the stale value must never be re-submitted on
// Enter.
assert.equal(sent[0].payload.defaultSavePassword, false);
drainPendingRequests(sent);
});
+5
View File
@@ -570,6 +570,11 @@ declare global {
savedPassword?: string | null;
/** When false, UI must not offer saving the response as the host password. */
allowSavePassword?: boolean;
/** When true, pre-check "Save password" — only from emitters with a
* genuine failed-auth rejection signal for the round. Same-shape
* keyboard-interactive re-asks carry no rejection signal and never
* get it (#3556/#3558). */
defaultSavePassword?: boolean;
scope?: "terminal" | "external";
bootEpoch?: number;
}) => void