Skip to content
Merged
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
38 changes: 32 additions & 6 deletions src/main/ipc/appIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getAppSettings } from '../settings';
import { getAppById, OPEN_IN_APPS, type OpenInAppId, type PlatformKey } from '@shared/openInApps';
import { databaseService } from '../services/DatabaseService';
import { buildExternalToolEnv } from '../utils/childProcessEnv';
import { quoteShellArg } from '../utils/shellEscape';

const UNKNOWN_VERSION = 'unknown';

Expand Down Expand Up @@ -184,6 +185,23 @@ export function registerAppIpc() {
ipcMain.handle('app:openExternal', async (_event, url: string) => {
try {
if (!url || typeof url !== 'string') throw new Error('Invalid URL');

// Security: Validate URL protocol to prevent local file access and dangerous protocols
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
let parsedUrl: URL;

try {
parsedUrl = new URL(url);
} catch {
throw new Error('Invalid URL format');
}

if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) {
throw new Error(
`Protocol "${parsedUrl.protocol}" is not allowed. Only http and https URLs are permitted.`
);
}

await shell.openExternal(url);
return { success: true };
} catch (error) {
Expand Down Expand Up @@ -254,19 +272,24 @@ export function registerAppIpc() {
}

// Construct remote SSH URL or command based on the app
// Security: Escape all user-controlled values to prevent command injection
const safeHost = encodeURIComponent(connection.host);
const safeTarget = encodeURIComponent(target);

if (appId === 'vscode') {
// VS Code Remote SSH URL format: vscode://vscode-remote/ssh-remote+hostname/path
const remoteUrl = `vscode://vscode-remote/ssh-remote+${connection.host}${target}`;
const remoteUrl = `vscode://vscode-remote/ssh-remote+${safeHost}${target}`;
await shell.openExternal(remoteUrl);
return { success: true };
} else if (appId === 'cursor') {
// Cursor uses its own URL scheme for remote SSH
const remoteUrl = `cursor://vscode-remote/ssh-remote+${connection.host}${target}`;
const remoteUrl = `cursor://vscode-remote/ssh-remote+${safeHost}${target}`;
await shell.openExternal(remoteUrl);
return { success: true };
} else if (appId === 'terminal' && platform === 'darwin') {
// macOS Terminal.app - execute SSH command
const sshCommand = `ssh ${connection.username}@${connection.host} -p ${connection.port} -t "cd ${target} && exec \\$SHELL"`;
// Security: Use quoteShellArg to prevent command injection
const sshCommand = `ssh ${quoteShellArg(connection.username)}@${quoteShellArg(connection.host)} -p ${quoteShellArg(String(connection.port))} -t "cd ${quoteShellArg(target)} && exec \\$SHELL"`;
// Properly escape for AppleScript
const escapedCommand = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const terminalCommand = `osascript -e 'tell application "Terminal" to do script "${escapedCommand}"' -e 'tell application "Terminal" to activate'`;
Expand All @@ -280,7 +303,8 @@ export function registerAppIpc() {
return { success: true };
} else if (appId === 'iterm2' && platform === 'darwin') {
// iTerm2 - execute SSH command
const sshCommand = `ssh ${connection.username}@${connection.host} -p ${connection.port} -t "cd ${target} && exec \\$SHELL"`;
// Security: Use quoteShellArg to prevent command injection
const sshCommand = `ssh ${quoteShellArg(connection.username)}@${quoteShellArg(connection.host)} -p ${quoteShellArg(String(connection.port))} -t "cd ${quoteShellArg(target)} && exec \\$SHELL"`;
const escapedCommand = sshCommand.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const terminalCommand = `osascript -e 'tell application "iTerm" to create window with default profile command "${escapedCommand}"'`;

Expand All @@ -293,14 +317,16 @@ export function registerAppIpc() {
return { success: true };
} else if (appId === 'warp' && platform === 'darwin') {
// Warp - use URL scheme with SSH command
const sshCommand = `ssh ${connection.username}@${connection.host} -p ${connection.port} -t "cd ${target} && exec \\$SHELL"`;
// Security: Use quoteShellArg to prevent command injection
const sshCommand = `ssh ${quoteShellArg(connection.username)}@${quoteShellArg(connection.host)} -p ${quoteShellArg(String(connection.port))} -t "cd ${quoteShellArg(target)} && exec \\$SHELL"`;
await shell.openExternal(
`warp://action/new_window?cmd=${encodeURIComponent(sshCommand)}`
);
return { success: true };
} else if (appId === 'ghostty') {
// Ghostty - execute SSH command directly
const sshCommand = `ssh ${connection.username}@${connection.host} -p ${connection.port} -t "cd ${target} && exec \\$SHELL"`;
// Security: Use quoteShellArg to prevent command injection
const sshCommand = `ssh ${quoteShellArg(connection.username)}@${quoteShellArg(connection.host)} -p ${quoteShellArg(String(connection.port))} -t "cd ${quoteShellArg(target)} && exec \\$SHELL"`;
const quoted = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
const terminalCommand = `ghostty -e ${quoted(sshCommand)}`;

Expand Down
5 changes: 4 additions & 1 deletion src/main/ipc/githubIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { promisify } from 'util';
import * as path from 'path';
import * as fs from 'fs';
import { homedir } from 'os';
import { quoteShellArg } from '../utils/shellEscape';

const execAsync = promisify(exec);
const githubService = new GitHubService();
Expand Down Expand Up @@ -458,7 +459,9 @@ export function registerGithubIpc() {
if (!cloneResult.success) {
// Cleanup: delete GitHub repo on clone failure
try {
await execAsync(`gh repo delete ${owner}/${name} --yes`, {
// Security: Use quoteShellArg to prevent command injection
const repoRef = `${quoteShellArg(owner)}/${quoteShellArg(name)}`;
await execAsync(`gh repo delete ${repoRef} --yes`, {
timeout: 10000,
});
} catch (cleanupError) {
Expand Down
25 changes: 22 additions & 3 deletions src/main/services/GitHubService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec } from 'child_process';
import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import * as path from 'path';
import * as fs from 'fs';
Expand Down Expand Up @@ -426,8 +426,27 @@ export class GitHubService {
// Check if gh CLI is installed first
await execAsync('gh --version');

// Authenticate gh CLI with our token
await execAsync(`echo "${token}" | gh auth login --with-token`);
// Security: Authenticate gh CLI with token via stdin (not shell interpolation)
// This prevents command injection if token contains shell metacharacters
await new Promise<void>((resolve, reject) => {
const child = spawn('gh', ['auth', 'login', '--with-token'], {
stdio: ['pipe', 'pipe', 'pipe'],
});

child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`gh auth login failed with code ${code}`));
}
});

child.on('error', reject);

// Write token to stdin and close it
child.stdin.write(token);
child.stdin.end();
});
} catch (error) {
console.warn('Could not authenticate gh CLI (may not be installed):', error);
// Don't throw - OAuth still succeeded even if gh CLI isn't available
Expand Down