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:
20
App.tsx
20
App.tsx
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 密钥密码',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user