mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e5aaae7f0 | ||
|
|
035d877585 | ||
|
|
aa0fd73693 | ||
|
|
0226eba320 | ||
|
|
ba4a0fd69a |
No files matched your search
@@ -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}"\\)`));
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user