Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion src/main/ipc/sshIpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ipcMain } from 'electron';
import { execFile } from 'child_process';
import { SSH_IPC_CHANNELS } from '../../shared/ssh/types';
import { sshService } from '../services/ssh/SshService';
import { SshCredentialService } from '../services/ssh/SshCredentialService';
Expand Down Expand Up @@ -50,7 +51,7 @@ function mapRowToConfig(row: {
host: row.host,
port: row.port,
username: row.username,
authType: row.authType as 'password' | 'key' | 'agent',
authType: row.authType as 'password' | 'key' | 'agent' | 'gssapi',
privateKeyPath: row.privateKeyPath ?? undefined,
useAgent: row.useAgent === 1,
};
Expand Down Expand Up @@ -161,6 +162,61 @@ export function registerSshIpc() {
config: SshConfig & { password?: string; passphrase?: string }
): Promise<ConnectionTestResult> => {
try {
// GSSAPI connections use system ssh for testing (ssh2 doesn't support GSSAPI)
if (config.authType === 'gssapi') {
return new Promise((resolve) => {
const startTime = Date.now();
const debugLogs: string[] = [];

const sshArgs = [
'-o',
'GSSAPIAuthentication=yes',
'-o',
'PreferredAuthentications=gssapi-with-mic,gssapi-keyex',
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'BatchMode=yes',
'-o',
'ConnectTimeout=10',
'-v', // Verbose for debug logs
'-p',
String(config.port),
'-l',
config.username,
config.host,
'echo __EMDASH_SSH_OK__',
];

execFile(
'ssh',
sshArgs,
{
timeout: 15000,
env: {
...process.env,
KRB5CCNAME: process.env.KRB5CCNAME || '',
},
},
(err, stdout, stderr) => {
const latency = Date.now() - startTime;

// Capture verbose ssh output as debug logs
if (stderr) {
debugLogs.push(...stderr.split('\n').filter(Boolean));
}

if (stdout && stdout.includes('__EMDASH_SSH_OK__')) {
resolve({ success: true, latency, debugLogs });
} else {
const errorMsg = err?.message || stderr?.trim() || 'GSSAPI authentication failed';
resolve({ success: false, error: errorMsg, debugLogs });
}
}
);
});
}

const { Client } = await import('ssh2');
const debugLogs: string[] = [];
const testClient = new Client();
Expand Down Expand Up @@ -571,6 +627,43 @@ export function registerSshIpc() {
return { success: false, error: 'Access denied: path is restricted' };
}

// GSSAPI connections use command-based file operations
if (sshService.isGssapiConnection(connectionId)) {
const result = await sshService.executeCommand(
connectionId,
`ls -la --time-style=+%s ${quoteShellArg(path)} 2>/dev/null`
Comment on lines +632 to +634
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--time-style is a GNU ls extension that breaks on macOS/BSD remote servers

--time-style=+%s is specific to GNU coreutils ls and is not available on macOS or BSD systems. If the remote host runs macOS, this command will exit non-zero with ls: illegal option -- - (the long option form is not supported), causing all GSSAPI directory listings to fail with a generic "Failed to list files" error.

Consider a more portable approach using stat for timestamps, or fall back gracefully. For example, using ls -la without --time-style and then separately fetching timestamps, or checking uname at connection time. At minimum, the error message could hint at the root cause when this fails.

);
if (result.exitCode !== 0) {
return { success: false, error: `Failed to list files: ${result.stderr}` };
}

const entries: FileEntry[] = [];
for (const line of result.stdout.split('\n')) {
// Parse ls -la output: permissions links owner group size timestamp name
const match = line.match(
/^([dlscp-])([rwxsStT-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\d+)\s+(.+)$/
);
if (!match) continue;
const [, typeChar, perms, sizeStr, mtimeStr, name] = match;
if (name === '.' || name === '..') continue;
Comment on lines +643 to +648
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlink names include -> target path

When ls -la lists a symlink, the output format is lrwxrwxrwx ... name -> /path/to/target. The regex captures (.+) for the name field, which means symlink entries will have names like "somefile -> /etc/passwd" instead of just "somefile". This produces incorrect name and path values for every symlink in the directory.

A fix would be to strip the arrow and target from symlink names:

Suggested change
const match = line.match(
/^([dlscp-])([rwxsStT-]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\d+)\s+(.+)$/
);
if (!match) continue;
const [, typeChar, perms, sizeStr, mtimeStr, name] = match;
if (name === '.' || name === '..') continue;
const [, typeChar, perms, sizeStr, mtimeStr, name] = match;
const displayName = typeChar === 'l' ? name.replace(/ -> .+$/, '') : name;
if (displayName === '.' || displayName === '..') continue;

And use displayName in the entries.push() call instead of name:

            entries.push({
              path: `${path}/${displayName}`.replace(/\/+/g, '/'),
              name: displayName,
              ...


let type: 'file' | 'directory' | 'symlink' = 'file';
if (typeChar === 'd') type = 'directory';
else if (typeChar === 'l') type = 'symlink';

entries.push({
path: `${path}/${name}`.replace(/\/+/g, '/'),
name,
type,
size: parseInt(sizeStr, 10),
modifiedAt: new Date(parseInt(mtimeStr, 10) * 1000),
permissions: perms,
});
}

return { success: true, files: entries };
}

const sftp = await sshService.getSftp(connectionId);

return new Promise((resolve) => {
Expand Down Expand Up @@ -622,6 +715,18 @@ export function registerSshIpc() {
return { success: false, error: 'Access denied: path is restricted' };
}

// GSSAPI connections use command-based file operations
if (sshService.isGssapiConnection(connectionId)) {
const result = await sshService.executeCommand(
connectionId,
`cat ${quoteShellArg(path)}`
);
if (result.exitCode !== 0) {
return { success: false, error: `Failed to read file: ${result.stderr}` };
}
return { success: true, content: result.stdout };
}

const sftp = await sshService.getSftp(connectionId);

return new Promise((resolve) => {
Expand Down Expand Up @@ -655,6 +760,19 @@ export function registerSshIpc() {
return { success: false, error: 'Access denied: path is restricted' };
}

// GSSAPI connections use command-based file operations
if (sshService.isGssapiConnection(connectionId)) {
const encoded = Buffer.from(content, 'utf-8').toString('base64');
const result = await sshService.executeCommand(
connectionId,
`echo ${quoteShellArg(encoded)} | base64 -d > ${quoteShellArg(path)}`
);
if (result.exitCode !== 0) {
return { success: false, error: `Failed to write file: ${result.stderr}` };
}
return { success: true };
}

const sftp = await sshService.getSftp(connectionId);

return new Promise((resolve) => {
Expand Down
117 changes: 88 additions & 29 deletions src/main/services/RemotePtyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,36 @@ export class RemotePtyService extends EventEmitter {
* @throws Error if connection not found or shell creation fails
*/
async startRemotePty(options: RemotePtyOptions): Promise<RemotePty> {
// Build the remote command (shared between ssh2 and GSSAPI paths)
const envEntries = Object.entries(options.env || {}).filter(([k]) => {
if (!isValidEnvVarName(k)) {
console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`);
return false;
}
return true;
});
const envVars = envEntries.map(([k, v]) => `export ${k}=${quoteShellArg(v)}`).join(' && ');
const cdCommand = options.cwd ? `cd ${quoteShellArg(options.cwd)}` : '';
const autoApproveFlag = options.autoApprove ? ' --full-auto' : '';

// Validate shell against allowlist (HIGH #5)
const shellBinary = options.shell.split(/\s+/)[0];
if (!ALLOWED_SHELLS.has(shellBinary)) {
throw new Error(
`Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}`
);
}

const fullCommand = [envVars, cdCommand, `${options.shell}${autoApproveFlag}`]
.filter(Boolean)
.join(' && ');

// GSSAPI connections: spawn system ssh with ControlMaster socket
if (this.sshService.isGssapiConnection(options.connectionId)) {
return this.startGssapiPty(options, fullCommand);
}

// Standard ssh2 connections
const connection = this.sshService.getConnection(options.connectionId);
if (!connection) {
throw new Error(`Connection ${options.connectionId} not found`);
Expand All @@ -77,35 +107,6 @@ export class RemotePtyService extends EventEmitter {
return;
}

// Build command with environment and cwd
// Validate env var keys to prevent injection (CRITICAL #1)
const envEntries = Object.entries(options.env || {}).filter(([k]) => {
if (!isValidEnvVarName(k)) {
console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`);
return false;
}
return true;
});
const envVars = envEntries.map(([k, v]) => `export ${k}=${quoteShellArg(v)}`).join(' && ');

const cdCommand = options.cwd ? `cd ${quoteShellArg(options.cwd)}` : '';
const autoApproveFlag = options.autoApprove ? ' --full-auto' : '';

// Validate shell against allowlist (HIGH #5)
const shellBinary = options.shell.split(/\s+/)[0];
if (!ALLOWED_SHELLS.has(shellBinary)) {
reject(
new Error(
`Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}`
)
);
return;
}

const fullCommand = [envVars, cdCommand, `${options.shell}${autoApproveFlag}`]
.filter(Boolean)
.join(' && ');

// Send initial command
stream.write(fullCommand + '\n');

Expand Down Expand Up @@ -138,6 +139,64 @@ export class RemotePtyService extends EventEmitter {
});
}

/**
* Starts a remote PTY session for GSSAPI connections using system ssh with ControlMaster.
*/
private async startGssapiPty(
options: RemotePtyOptions,
remoteCommand: string
): Promise<RemotePty> {
const sshArgs = this.sshService.getGssapiSshArgs(options.connectionId);
if (!sshArgs) {
throw new Error(`GSSAPI connection ${options.connectionId} not found`);
}

// Use node-pty to spawn ssh -tt with the ControlMaster socket
let pty: typeof import('node-pty');
try {
pty = require('node-pty');
} catch (e: any) {
throw new Error(`PTY unavailable: ${e?.message || String(e)}`);
}

const proc = pty.spawn('ssh', ['-tt', ...sshArgs, remoteCommand], {
name: 'xterm-256color',
cols: 120,
rows: 32,
env: {
TERM: 'xterm-256color',
HOME: process.env.HOME || require('os').homedir(),
PATH: process.env.PATH || '',
KRB5CCNAME: process.env.KRB5CCNAME || '',
},
});

// Send initial prompt if provided
if (options.initialPrompt) {
setTimeout(() => {
proc.write(options.initialPrompt + '\n');
}, 500);
}

const remotePty: RemotePty = {
id: options.id,
write: (data: string) => proc.write(data),
resize: (cols: number, rows: number) => proc.resize(cols, rows),
kill: () => proc.kill(),
onData: (callback) => proc.onData(callback),
onExit: (callback) => proc.onExit(({ exitCode }) => callback(exitCode)),
};

this.ptys.set(options.id, remotePty);

proc.onExit(() => {
this.ptys.delete(options.id);
this.emit('exit', options.id);
});

return remotePty;
}

/**
* Writes data to a remote PTY session.
*
Expand Down
Loading