fix: support CSV password import and save password in keyboard-interactive auth (#629)

* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

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

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

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

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

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

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

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

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

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

* fix: only skip formula guard for data rows, not header row

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈大猫
2026-04-05 14:39:39 +08:00
committed by GitHub
parent 7db4b18cce
commit 24df4b6548
5 changed files with 82 additions and 36 deletions

20
App.tsx
View File

@@ -722,6 +722,7 @@ function App({ settings }: { settings: SettingsState }) {
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
@@ -736,14 +737,29 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {

View File

@@ -1644,10 +1644,7 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',

View File

@@ -1651,10 +1651,7 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',

View File

@@ -4,7 +4,7 @@
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request]);
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);

View File

@@ -155,6 +155,7 @@ const createHost = (input: {
label?: string;
hostname: string;
username?: string;
password?: string;
port?: number;
protocol?: Exclude<HostProtocol, "mosh">;
group?: string;
@@ -167,6 +168,7 @@ const createHost = (input: {
hostname: input.hostname.trim(),
port: input.port ?? DEFAULT_SSH_PORT,
username: input.username?.trim() ?? "",
password: input.password || undefined,
group: normalizeGroupPath(input.group),
tags: (input.tags ?? []).filter(Boolean),
os: "linux",
@@ -189,6 +191,7 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
duplicates++;
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
existing.tags = mergedTags;
if (!existing.password && host.password) existing.password = host.password;
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
@@ -333,6 +336,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
const passwordIdx = findHeaderIndex(header, ["password", "pass", "passwd"]);
if (hostnameIdx === -1) {
return {
@@ -378,12 +382,14 @@ const importFromCsv = (text: string): VaultImportResult => {
"ssh";
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
parsedHosts.push(
createHost({
label,
hostname: target.hostname,
username,
password,
port,
protocol,
group,
@@ -993,12 +999,12 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root"]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu"]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin"]);
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
}
const escapeCsv = (value: string) => {
@@ -1011,13 +1017,14 @@ export const getVaultCsvTemplate = (
};
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
const escapeCsv = (value: string, skipFormulaGuard = false) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
// Skip for password fields to preserve credentials verbatim for round-trip
if (!skipFormulaGuard && /^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
@@ -1059,10 +1066,12 @@ const exportHostsToCsv = (hosts: Host[]): string => {
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
host.password ?? "",
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
const passwordColIdx = header.indexOf("Password");
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(c, rowIdx > 0 && i === passwordColIdx)).join(",")).join("\r\n") + "\r\n";
};
interface ExportHostsResult {