1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
|
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { platform } from 'node:os';
import { URL } from 'node:url';
const execFileAsync = promisify(execFile);
/**
* Validates that a URL is safe to open in a browser.
* Only allows HTTP and HTTPS URLs to prevent command injection.
*
* @param url The URL to validate
* @throws Error if the URL is invalid or uses an unsafe protocol
*/
function validateUrl(url: string): void {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch (_error) {
throw new Error(`Invalid URL: ${url}`);
}
// Only allow HTTP and HTTPS protocols
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(
`Unsafe protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
);
}
// Additional validation: ensure no newlines or control characters
// eslint-disable-next-line no-control-regex
if (/[\r\n\x00-\x1f]/.test(url)) {
throw new Error('URL contains invalid characters');
}
}
/**
* Opens a URL in the default browser using platform-specific commands.
* This implementation avoids shell injection vulnerabilities by:
* 1. Validating the URL to ensure it's HTTP/HTTPS only
* 2. Using execFile instead of exec to avoid shell interpretation
* 3. Passing the URL as an argument rather than constructing a command string
*
* @param url The URL to open
* @throws Error if the URL is invalid or if opening the browser fails
*/
export async function openBrowserSecurely(url: string): Promise<void> {
// Validate the URL first
validateUrl(url);
const platformName = platform();
let command: string;
let args: string[];
switch (platformName) {
case 'darwin':
// macOS
command = 'open';
args = [url];
break;
case 'win32':
// Windows - use PowerShell with Start-Process
// This avoids the cmd.exe shell which is vulnerable to injection
command = 'powershell.exe';
args = [
'-NoProfile',
'-NonInteractive',
'-WindowStyle',
'Hidden',
'-Command',
`Start-Process '${url.replace(/'/g, "''")}'`,
];
break;
case 'linux':
case 'freebsd':
case 'openbsd':
// Linux and BSD variants
// Try xdg-open first, fall back to other options
command = 'xdg-open';
args = [url];
break;
default:
throw new Error(`Unsupported platform: ${platformName}`);
}
const options: Record<string, unknown> = {
// Don't inherit parent's environment to avoid potential issues
env: {
...process.env,
// Ensure we're not in a shell that might interpret special characters
SHELL: undefined,
},
// Detach the browser process so it doesn't block
detached: true,
stdio: 'ignore',
};
try {
await execFileAsync(command, args, options);
} catch (error) {
// For Linux, try fallback commands if xdg-open fails
if (
(platformName === 'linux' ||
platformName === 'freebsd' ||
platformName === 'openbsd') &&
command === 'xdg-open'
) {
const fallbackCommands = [
'gnome-open',
'kde-open',
'firefox',
'chromium',
'google-chrome',
];
for (const fallbackCommand of fallbackCommands) {
try {
await execFileAsync(fallbackCommand, [url], options);
return; // Success!
} catch {
// Try next command
continue;
}
}
}
// Re-throw the error if all attempts failed
throw new Error(
`Failed to open browser: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
/**
* Checks if the current environment should attempt to launch a browser.
* This is the same logic as in browser.ts for consistency.
*
* @returns True if the tool should attempt to launch a browser
*/
export function shouldLaunchBrowser(): boolean {
// A list of browser names that indicate we should not attempt to open a
// web browser for the user.
const browserBlocklist = ['www-browser'];
const browserEnv = process.env.BROWSER;
if (browserEnv && browserBlocklist.includes(browserEnv)) {
return false;
}
// Common environment variables used in CI/CD or other non-interactive shells.
if (process.env.CI || process.env.DEBIAN_FRONTEND === 'noninteractive') {
return false;
}
// The presence of SSH_CONNECTION indicates a remote session.
// We should not attempt to launch a browser unless a display is explicitly available
// (checked below for Linux).
const isSSH = !!process.env.SSH_CONNECTION;
// On Linux, the presence of a display server is a strong indicator of a GUI.
if (platform() === 'linux') {
// These are environment variables that can indicate a running compositor on Linux.
const displayVariables = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'];
const hasDisplay = displayVariables.some((v) => !!process.env[v]);
if (!hasDisplay) {
return false;
}
}
// If in an SSH session on a non-Linux OS (e.g., macOS), don't launch browser.
// The Linux case is handled above (it's allowed if DISPLAY is set).
if (isSSH && platform() !== 'linux') {
return false;
}
// For non-Linux OSes, we generally assume a GUI is available
// unless other signals (like SSH) suggest otherwise.
return true;
}
|