Compare commits

...

5 Commits

Author SHA1 Message Date
陈大猫
664fe90c10 feat: add legacy SSH algorithm support for older network equipment (#216)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-01 07:45:13 +08:00
Rory Chou
2215d52b09 feat: credential protection guards for enc:v1: placeholders (#212)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* feat: add credential protection guards for enc:v1: placeholders

Prevent encrypted credential placeholders from being sent as
actual passwords when safeStorage decryption is unavailable
(e.g. different device/user profile). Adds guards at terminal
connection, cloud sync, and settings boundaries with user-facing
warnings and i18n support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: validate base64 format in encrypted credential detection

Only treat values as encrypted placeholders when the content after
the enc:v1: prefix is valid base64. Prevents false positives if a
real password happens to start with the prefix literal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve regressions in master-key change flow and credential placeholder detection

- Make ensureSyncablePayload non-blocking in changeMasterKey handler so
  success toast and dialog close always fire after a successful key change,
  even when the payload contains unresolved enc:v1: placeholders
- Add MIN_CIPHERTEXT_BASE64_LENGTH (32) threshold to
  isEncryptedCredentialPlaceholder to avoid false-positive matches on
  plaintext credentials that happen to start with enc:v1: (e.g. enc:v1:hello)

* fix: clean up chain-progress listener on credential reentry and gate proxy check on auth usage

- Unsubscribe onChainProgress before returning in needsCredentialReentry
  branch to prevent listener leaks across connection attempts
- Only block connection for undecryptable proxy password when proxy
  authentication is actually in use (has a username)

* fix: reduce enc:v1 placeholder false positives

* fix: require syncable payload before master key rotation

---------

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-02-25 15:32:24 +08:00
Rory Chou
c9059a4f29 feat: add swap usage display in server stats (#210)
Collect SwapTotal and SwapFree from /proc/meminfo and display swap
usage in the memory HoverCard with a dedicated progress bar (rose color).
Only shown when the server has swap configured (swapTotal > 0).

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:11 +08:00
Rory Chou
4445bf578c security: harden external navigation / window.open (#209) 2026-02-14 16:21:11 +08:00
Rory Chou
f719350507 fix(macos): restore main window on Dock activate (#208) 2026-02-14 12:20:48 +08:00
25 changed files with 849 additions and 136 deletions

25
App.tsx
View File

@@ -14,6 +14,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
@@ -1082,6 +1083,30 @@ function App({ settings }: { settings: SettingsState }) {
})();
}, [openSettingsWindow, t]);
const hasShownCredentialProtectionWarningRef = useRef(false);
useEffect(() => {
if (hasShownCredentialProtectionWarningRef.current) return;
let cancelled = false;
void (async () => {
const available = await getCredentialProtectionAvailability();
if (cancelled || available !== false) return;
hasShownCredentialProtectionWarningRef.current = true;
toast.warning(t('credentials.protectionUnavailable.message'), {
title: t('credentials.protectionUnavailable.title'),
actionLabel: t('credentials.protectionUnavailable.action'),
duration: 10000,
onClick: handleOpenSettings,
});
})();
return () => {
cancelled = true;
};
}, [handleOpenSettings, t]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
}, [setDraggingSessionId]);

View File

@@ -57,6 +57,9 @@ const en: Messages = {
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
@@ -80,6 +83,14 @@ const en: Messages = {
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -324,6 +335,7 @@ const en: Messages = {
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
@@ -781,6 +793,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -916,6 +932,10 @@ const en: Messages = {
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
@@ -952,6 +972,10 @@ const en: Messages = {
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',

View File

@@ -42,6 +42,9 @@ const zhCN: Messages = {
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
@@ -65,6 +68,14 @@ const zhCN: Messages = {
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
'settings.system.credentials.available': '可用(系统钥匙串正常)',
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
'settings.system.credentials.unknown': '未知(当前环境不支持)',
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -191,6 +202,7 @@ const zhCN: Messages = {
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
'time.minutesAgo': '{minutes} 分钟前',
@@ -496,6 +508,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -602,6 +618,10 @@ const zhCN: Messages = {
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.swap': '交换空间',
'terminal.serverStats.swapUsed': '已用交换',
'terminal.serverStats.swapFree': '空闲交换',
'terminal.serverStats.swapTotal': '总计',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
@@ -638,6 +658,10 @@ const zhCN: Messages = {
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.disconnected': '已断开',

View File

@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import type { SyncPayload } from '../../domain/sync';
import { toast } from '../../components/ui/toast';
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
const results = await sync.syncNow(payload);
for (const result of results.values()) {

View File

@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback

View File

@@ -32,6 +32,9 @@ import {
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
</div>
<Button
onClick={() => window.open(verificationUri, '_blank')}
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
className="w-full gap-2 mb-4"
>
<ExternalLink size={14} />
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length === 0) return true;
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
return false;
},
[t],
);
// Handle conflict detection
useEffect(() => {
if (sync.currentConflict) {
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const handleSync = async (provider: CloudProvider) => {
try {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) return;
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
toast.success(t('cloudSync.resolve.uploaded'));
}
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
let payloadForReencrypt: SyncPayload | null = null;
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) {
setChangeKeyError(t('sync.credentialsUnavailable'));
return;
}
payloadForReencrypt = payload;
}
setIsChangingKey(true);
try {
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
await sync.syncNow(payload);
if (payloadForReencrypt) {
await sync.syncNow(payloadForReencrypt);
}
toast.success(t('cloudSync.changeKey.updatedToast'));

View File

@@ -16,6 +16,7 @@ import {
Plus,
Settings2,
Shield,
ShieldAlert,
Tag,
TerminalSquare,
User,
@@ -1230,6 +1231,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
</div>
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
enabled={!!form.legacyAlgorithms}
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.legacyAlgorithms.desc")}
</p>
{form.legacyAlgorithms && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400">
{t("hostDetails.legacyAlgorithms.warning")}
</p>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -42,6 +42,7 @@ interface SFTPModalProps {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
@@ -526,7 +527,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {

View File

@@ -416,6 +416,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -1299,6 +1300,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
@@ -1618,6 +1647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}

View File

@@ -4,6 +4,7 @@
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
@@ -61,6 +62,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
@@ -81,6 +84,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
loadTempDirInfo();
}, [loadTempDirInfo]);
const loadCredentialProtectionStatus = useCallback(async () => {
setIsCheckingCredentials(true);
try {
const available = await getCredentialProtectionAvailability();
setCredentialsAvailable(available);
} finally {
setIsCheckingCredentials(false);
}
}, []);
useEffect(() => {
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -201,6 +218,59 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
{t("settings.system.credentials.status")}
</p>
<p
className={cn(
"text-sm font-medium mt-1",
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
)}
>
{isCheckingCredentials
? t("settings.system.credentials.checking")
: credentialsAvailable === true
? t("settings.system.credentials.available")
: credentialsAvailable === false
? t("settings.system.credentials.unavailable")
: t("settings.system.credentials.unknown")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadCredentialProtectionStatus}
disabled={isCheckingCredentials}
className="gap-1.5"
>
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
</div>
{credentialsAvailable === false && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("settings.system.credentials.unavailableHint")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</div>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
@@ -112,6 +114,7 @@ export const useSftpModalSession = ({
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
sftpIdRef.current = sftpId;
return sftpId;
@@ -131,6 +134,7 @@ export const useSftpModalSession = ({
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
openSftp,
]);

View File

@@ -31,6 +31,8 @@ export interface ServerStats {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
@@ -66,6 +68,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
@@ -109,6 +113,8 @@ export function useServerStats({
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
swapTotal: result.stats.swapTotal ?? null,
swapUsed: result.stats.swapUsed ?? null,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
@@ -155,6 +161,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,

View File

@@ -4,6 +4,10 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import {
isEncryptedCredentialPlaceholder,
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
@@ -85,6 +89,7 @@ export type TerminalSessionStartersContext = {
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
t?: (key: string) => string;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
@@ -194,6 +199,12 @@ const runDistroDetection = async (
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const tr = (key: string, fallback: string): string => {
const translated = ctx.t?.(key);
if (!translated || translated === key) return fallback;
return translated;
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
@@ -227,9 +238,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
const effectiveUsername = resolvedAuth.username || "root";
const effectivePassword = resolvedAuth.password;
const key = resolvedAuth.key;
const effectivePassphrase = resolvedAuth.passphrase;
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
@@ -244,16 +257,19 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
password: sanitizeCredentialValue(rawProxyPassword),
}
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -261,14 +277,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
});
const jumpKey = jumpAuth.key;
const rawJumpPassword = jumpAuth.password;
const rawJumpPrivateKey = jumpKey?.privateKey;
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
password: jumpPassword,
privateKey: jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
@@ -276,6 +308,38 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
});
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const message = tr(
"terminal.auth.proxyCredentialsUnavailable",
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
);
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (jumpHostsWithUnavailableCredentials.length > 0) {
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
const suffix =
jumpHostsWithUnavailableCredentials.length > 2
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
: "";
const base = tr(
"terminal.auth.jumpCredentialsUnavailable",
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
);
const message = `${base} (${jumpList}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const totalHops = jumpHosts.length + 1;
let unsubscribeChainProgress: (() => void) | undefined;
@@ -334,8 +398,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keySource: attempt.key?.source,
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
: undefined,
agentForwarding: ctx.host.agentForwarding,
legacyAlgorithms: ctx.host.legacyAlgorithms,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
@@ -349,9 +416,46 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
let id: string;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
if (unsubscribeChainProgress) unsubscribeChainProgress();
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
]);
ctx.setStatus("connecting");
ctx.setChainProgress(null);
return;
}
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.keyUnavailableFallbackPassword",
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
),
]);
}
if (hasKeyMaterial) {
try {

View File

@@ -316,7 +316,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (ctx.terminalBackend.openExternalAvailable()) {
void ctx.terminalBackend.openExternal(uri);
} else {
window.open(uri, "_blank");
const safeUri = String(uri || "");
if (/^https?:\/\//i.test(safeUri)) {
window.open(safeUri, "_blank", "noopener,noreferrer");
} else {
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
}
}
});
term.loadAddon(webLinksAddon);

92
domain/credentials.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { SyncPayload } from "./sync";
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
*/
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
/**
* Chromium/Electron safeStorage ciphertext carries known platform headers:
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
*
* We validate the base64 payload starts with one of these header signatures
* instead of relying only on prefix+length heuristics. This greatly reduces
* false positives for plaintext credentials that happen to start with "enc:v1:".
*
* References:
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
*/
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
"djEw", // "v10"
"djEx", // "v11"
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
] as const;
export const isEncryptedCredentialPlaceholder = (
value: string | undefined | null,
): value is string => {
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
return false;
}
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
if (!payload || !BASE64_RE.test(payload)) return false;
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
};
/**
* Strip enc:v1: placeholders from a single credential value.
* Used at the terminal connection boundary to avoid sending encrypted
* placeholders as actual passwords to SSH/Telnet servers.
*/
export const sanitizeCredentialValue = (
value: string | undefined,
): string | undefined => {
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
/**
* Scan a sync payload for any fields that still carry device-bound
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
* Used as a pre-upload guard to prevent pushing un-decryptable data.
*/
export const findSyncPayloadEncryptedCredentialPaths = (
payload: SyncPayload,
): string[] => {
const issues: string[] = [];
payload.hosts.forEach((host, index) => {
if (isEncryptedCredentialPlaceholder(host.password)) {
issues.push(`hosts[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
issues.push(`hosts[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
});
payload.keys.forEach((key, index) => {
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
issues.push(`keys[${index}].privateKey`);
}
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
issues.push(`keys[${index}].passphrase`);
}
});
payload.identities?.forEach((identity, index) => {
if (isEncryptedCredentialPlaceholder(identity.password)) {
issues.push(`identities[${index}].password`);
}
});
return issues;
};

View File

@@ -99,6 +99,8 @@ export interface Host {
// Host-level keyword highlighting (overrides/extends global settings)
keywordHighlightRules?: KeywordHighlightRule[];
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -194,7 +196,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
// Convert keyboard event to a key string
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
const parts: string[] = [];
if (isMac) {
if (e.metaKey) parts.push('⌘');
if (e.ctrlKey) parts.push('⌃');
@@ -206,7 +208,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Win');
}
// Get the key name
let keyName = e.key;
// Normalize special keys
@@ -221,12 +223,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
else if (keyName === 'Enter') keyName = '↵';
else if (keyName === 'Tab') keyName = '⇥';
else if (keyName.length === 1) keyName = keyName.toUpperCase();
// Don't include modifier keys themselves
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
return parts.join(' + ');
}
parts.push(keyName);
return parts.join(' + ');
};
@@ -234,7 +236,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
// Check if a keyboard event matches a key binding string
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
if (!keyStr || keyStr === 'Disabled') return false;
// Handle range patterns like "[1...9]"
if (keyStr.includes('[1...9]')) {
const basePattern = keyStr.replace('[1...9]', '');
@@ -244,7 +246,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const testStr = basePattern + key;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
// Handle arrow key patterns like "arrows"
if (keyStr.includes('arrows')) {
const basePattern = keyStr.replace('arrows', '');
@@ -252,18 +254,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
// Check if it's an arrow key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
// Map arrow key to symbol for matching
const arrowSymbol = key === 'ArrowUp' ? '↑'
const arrowSymbol = key === 'ArrowUp' ? '↑'
: key === 'ArrowDown' ? '↓'
: key === 'ArrowLeft' ? '←'
: '→';
: key === 'ArrowLeft' ? '←'
: '→';
// Check modifiers match the base pattern
const testStr = basePattern + arrowSymbol;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
const parsed = parseKeyCombo(keyStr);
if (!parsed) return false;
const { modifiers, key } = parsed;
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
@@ -271,14 +273,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
return false;
}
// Check modifiers
if (isMac) {
const needMeta = modifiers.includes('⌘');
const needCtrl = modifiers.includes('⌃');
const needAlt = modifiers.includes('⌥');
const needShift = modifiers.includes('Shift');
if (e.metaKey !== needMeta) return false;
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
@@ -288,13 +290,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const needAlt = modifiers.includes('Alt');
const needShift = modifiers.includes('Shift');
const needMeta = modifiers.includes('Win');
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
if (e.shiftKey !== needShift) return false;
if (e.metaKey !== needMeta) return false;
}
const normalizeKey = (rawKey: string): string => {
let normalizedKey = rawKey;
if (normalizedKey === ' ') normalizedKey = 'Space';
@@ -524,17 +526,17 @@ export interface RemoteFile {
export type WorkspaceNode =
| {
id: string;
type: 'pane';
sessionId: string;
}
id: string;
type: 'pane';
sessionId: string;
}
| {
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
export type WorkspaceViewMode = 'split' | 'focus';

View File

@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
return true;
};
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
/**
* Send message to renderer safely
*/
@@ -307,22 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, and password fallback
@@ -726,6 +749,7 @@ async function openSftp(event, options) {
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Use the tunneled socket if we have one

View File

@@ -173,6 +173,45 @@ const log = (msg, data) => {
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
* (lower priority than modern ones) for compatibility with older network equipment.
*/
function buildAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
@@ -277,22 +316,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, password, and default key fallback
@@ -465,22 +489,7 @@ async function startSSHSession(event, options) {
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Authentication for final target
@@ -1470,8 +1479,8 @@ async function getServerStats(event, payload) {
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get top 10 processes by memory - with BusyBox fallback
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
@@ -1525,6 +1534,8 @@ async function getServerStats(event, payload) {
let memBuffers = null;
let memCached = null;
let memUsed = null;
let swapTotal = null;
let swapUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
@@ -1571,6 +1582,16 @@ async function getServerStats(event, payload) {
memUsed = memTotal - memFree - memBuffers - memCached;
if (memUsed < 0) memUsed = 0;
}
// Parse swap info (fields 5 and 6)
if (memParts.length >= 6) {
const st = parseInt(memParts[4], 10);
const sf = parseInt(memParts[5], 10);
if (!isNaN(st)) swapTotal = st;
if (!isNaN(sf)) {
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
}
}
}
} else if (part.startsWith('PROCS:')) {
const procsStr = part.substring(6).trim();
@@ -1743,6 +1764,8 @@ async function getServerStats(event, payload) {
memFree, // Free memory in MB
memBuffers, // Buffers in MB
memCached, // Cached in MB
swapTotal, // Swap total in MB
swapUsed, // Swap used in MB
topProcesses, // Top 10 processes by memory
diskPercent, // Disk usage percentage for root partition (backward compat)
diskUsed, // Disk used in GB for root partition (backward compat)

View File

@@ -35,6 +35,7 @@ const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
@@ -368,6 +369,86 @@ function parseWindowOpenFeatures(features) {
};
}
function createExternalOnlyWindowOpenHandler(shell) {
return (details) => {
const targetUrl = details?.url;
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
}
return { action: "deny" };
};
}
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
const allowedPopupHosts = new Set([
// OAuth (PKCE loopback)
"accounts.google.com",
"login.microsoftonline.com",
"login.live.com",
]);
const isAllowedInAppPopupUrl = (rawUrl) => {
try {
const u = new URL(String(rawUrl));
if (u.protocol === "https:") {
return allowedPopupHosts.has(u.hostname);
}
if (u.protocol === "http:") {
// Allow ONLY the loopback OAuth callback page.
const isLoopback =
u.hostname === "127.0.0.1" || u.hostname === "localhost";
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
}
return false;
} catch {
return false;
}
};
return (details) => {
const targetUrl = details?.url;
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
return { action: "deny" };
}
// Default: open in system browser to reduce remote-content attack surface.
if (!isAllowedInAppPopupUrl(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// Sandboxed because this window renders remote content and does not need a preload bridge.
sandbox: true,
},
},
};
};
}
function attachOAuthLoadingOverlay(win) {
if (!win || win.isDestroyed?.()) return;
@@ -543,7 +624,7 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
@@ -611,6 +692,35 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
@@ -732,36 +842,18 @@ async function createWindow(electronModule, options) {
} catch {
// ignore
}
// Never allow chained popups from remote content windows.
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
attachOAuthLoadingOverlay(childWindow);
});
win.webContents.setWindowOpenHandler((details) => {
const url = details?.url;
if (!url || !/^https?:/i.test(url)) {
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
},
};
});
win.webContents.setWindowOpenHandler(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
// Register window control handlers
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
@@ -788,7 +880,7 @@ async function createWindow(electronModule, options) {
* Create or focus the settings window
*/
async function openSettingsWindow(electronModule, options) {
const { BrowserWindow } = electronModule;
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
@@ -830,6 +922,52 @@ async function openSettingsWindow(electronModule, options) {
settingsWindow = win;
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
try {
win.webContents?.setWindowOpenHandler?.(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
} catch {
// ignore
}
// Never allow chained popups from remote content windows spawned from settings.
win.webContents?.on?.("did-create-window", (childWindow) => {
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
});
// Same navigation hardening as the main window (settings has preload access too).
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
if (isMac) {
try {
win.setWindowButtonVisibility(true);

View File

@@ -289,7 +289,8 @@ const ensureKeyDir = async () => {
const writeKeyToDisk = async (keyId, privateKey) => {
if (!privateKey) return null;
await ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
const filename = `${safeId}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
@@ -644,9 +645,9 @@ const registerBridges = (win) => {
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = tempDirBridge.getTempDir();
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(netcattyTempDir)) {
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
const resolvedPath = path.resolve(String(filePath || ""));
if (!isPathInside(netcattyTempDir, resolvedPath)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
return { success: false };
}
@@ -755,8 +756,24 @@ if (!gotLock) {
} catch {}
});
// Re-create window on macOS dock click
// Re-create or focus window on macOS dock click
app.on("activate", () => {
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
// should bring it back. Fallback to creating a new window if none exists.
try {
const mainWin = windowManager.getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
if (mainWin.isMinimized?.()) mainWin.restore();
mainWin.show?.();
mainWin.focus?.();
try {
app.focus({ steal: true });
} catch {}
return;
}
} catch {}
if (focusMainWindow()) return;
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);

View File

@@ -854,4 +854,50 @@ const api = {
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
function getAllowedRendererOrigins() {
const origins = new Set(["app://netcatty"]);
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
try {
const u = new URL(devServerUrl);
origins.add(u.origin);
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
if (
u.hostname === "0.0.0.0" ||
u.hostname === "127.0.0.1" ||
u.hostname === "::1" ||
u.hostname === "[::1]" ||
u.hostname === "::" ||
u.hostname === "[::]"
) {
u.hostname = "localhost";
origins.add(u.origin);
}
} catch {
// ignore invalid dev URL
}
}
return origins;
}
function isTrustedRendererLocation(allowedOrigins) {
try {
const origin = window?.location?.origin;
return typeof origin === "string" && allowedOrigins.has(origin);
} catch {
return false;
}
}
const allowedOrigins = getAllowedRendererOrigins();
if (isTrustedRendererLocation(allowedOrigins)) {
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
} else {
// If a window navigates to an untrusted origin, do NOT expose the bridge.
try {
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
} catch {
// ignore
}
}

4
global.d.ts vendored
View File

@@ -72,6 +72,8 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Enable legacy SSH algorithms for older network equipment
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
sudo?: boolean;
}
@@ -200,6 +202,8 @@ declare global {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: Array<{ // Top 10 processes by memory
pid: string;
memPercent: number;

View File

@@ -0,0 +1,12 @@
import { netcattyBridge } from "./netcattyBridge";
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.credentialsAvailable) return null;
try {
return await bridge.credentialsAvailable();
} catch {
return null;
}
};

10
package-lock.json generated
View File

@@ -6378,14 +6378,14 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},

View File

@@ -82,6 +82,7 @@
"wait-on": "^9.0.3"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0"
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"
}
}
}