Skip to content

Commit 87588f7

Browse files
Merge pull request #308 from browserstack/fix/codeql-command-injection-openbrowser
fix: return browser-open command instead of spawning it (CodeQL command-injection)
2 parents 6fbf556 + 058048a commit 87588f7

2 files changed

Lines changed: 74 additions & 53 deletions

File tree

src/tools/applive-utils/start-session.ts

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js";
99
import { findDeviceByName } from "./device-search.js";
1010
import { pickVersion } from "./version-utils.js";
1111
import { DeviceEntry } from "./types.js";
12-
import childProcess from "child_process";
1312
import { BrowserStackConfig } from "../../lib/types.js";
1413
import envConfig from "../../config.js";
1514

@@ -114,42 +113,47 @@ export async function startSession(
114113
const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`;
115114

116115
if (!envConfig.REMOTE_MCP) {
117-
openBrowser(launchUrl);
116+
const openCommand = getOpenBrowserCommand(launchUrl);
117+
if (openCommand) {
118+
return [
119+
`App Live session URL: ${launchUrl}${note}`,
120+
``,
121+
`To open the session in the default browser, run:`,
122+
` ${openCommand}`,
123+
].join("\n");
124+
}
118125
}
119126

120127
return launchUrl + note;
121128
}
122129

123130
/**
124-
* Opens the launch URL in the default browser.
125-
* @param launchUrl - The URL to open.
126-
* @throws Will throw an error if the browser fails to open.
131+
* Returns the platform-appropriate shell command to open `launchUrl` in the
132+
* default browser, or null if the URL is not a trusted BrowserStack URL.
133+
*
134+
* The command is returned to the MCP client so the host agent can prompt the
135+
* user before executing it. The server itself never spawns a process, which
136+
* eliminates the command-injection surface entirely.
127137
*/
128-
function openBrowser(launchUrl: string): void {
138+
function getOpenBrowserCommand(launchUrl: string): string | null {
139+
let parsed: URL;
129140
try {
130-
const command =
131-
process.platform === "darwin"
132-
? ["open", launchUrl]
133-
: process.platform === "win32"
134-
? ["cmd", "/c", "start", launchUrl]
135-
: ["xdg-open", launchUrl];
136-
137-
// nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
138-
const child = childProcess.spawn(command[0], command.slice(1), {
139-
stdio: "ignore",
140-
detached: true,
141-
});
142-
143-
child.on("error", (error) => {
144-
logger.error(
145-
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
146-
);
147-
});
141+
parsed = new URL(launchUrl);
142+
} catch {
143+
logger.error(`Refusing to surface malformed URL: ${launchUrl}`);
144+
return null;
145+
}
148146

149-
child.unref();
150-
} catch (error) {
151-
logger.error(
152-
`Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`,
153-
);
147+
if (
148+
parsed.protocol !== "https:" ||
149+
!/(^|\.)browserstack\.com$/i.test(parsed.hostname)
150+
) {
151+
logger.error(`Refusing to surface untrusted URL: ${launchUrl}`);
152+
return null;
154153
}
154+
155+
const quoted = `"${parsed.toString()}"`;
156+
if (process.platform === "darwin") return `open ${quoted}`;
157+
if (process.platform === "win32") return `cmd /c start "" ${quoted}`;
158+
return `xdg-open ${quoted}`;
155159
}

src/tools/live-utils/start-session.ts

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logger from "../../logger.js";
2-
import childProcess from "child_process";
32
import { filterDesktop } from "./desktop-filter.js";
43
import { filterMobile } from "./mobile-filter.js";
54
import {
@@ -73,10 +72,21 @@ export async function startBrowserSession(
7372
isLocal,
7473
)
7574
: buildMobileUrl(args as MobileSearchArgs, entry as MobileEntry, isLocal);
75+
const note = entry.notes ? `, ${entry.notes}` : "";
76+
7677
if (!envConfig.REMOTE_MCP) {
77-
openBrowser(url);
78+
const openCommand = getOpenBrowserCommand(url);
79+
if (openCommand) {
80+
return [
81+
`Live session URL: ${url}${note}`,
82+
``,
83+
`To open the session in the default browser, run:`,
84+
` ${openCommand}`,
85+
].join("\n");
86+
}
7887
}
79-
return entry.notes ? `${url}, ${entry.notes}` : url;
88+
89+
return `${url}${note}`;
8090
}
8191

8292
function buildDesktopUrl(
@@ -125,28 +135,35 @@ function buildMobileUrl(
125135
return `https://live.browserstack.com/dashboard#${params.toString()}`;
126136
}
127137

128-
// ——— Open a browser window ———
138+
// ——— Build a browser-open command for the host agent ———
129139

130-
function openBrowser(launchUrl: string): void {
140+
/**
141+
* Returns the platform-appropriate shell command to open `launchUrl` in the
142+
* default browser, or null if the URL is not a trusted BrowserStack URL.
143+
*
144+
* The command is returned to the MCP client so the host agent can prompt the
145+
* user before executing it. The server itself never spawns a process, which
146+
* eliminates the command-injection surface entirely.
147+
*/
148+
function getOpenBrowserCommand(launchUrl: string): string | null {
149+
let parsed: URL;
131150
try {
132-
const command =
133-
process.platform === "darwin"
134-
? ["open", launchUrl]
135-
: process.platform === "win32"
136-
? ["cmd", "/c", "start", `""`, `"${launchUrl}"`]
137-
: ["xdg-open", launchUrl];
138-
139-
// nosemgrep:javascript.lang.security.detect-child-process.detect-child-process
140-
const child = childProcess.spawn(command[0], command.slice(1), {
141-
stdio: "ignore",
142-
detached: true,
143-
...(process.platform === "win32" ? { shell: true } : {}),
144-
});
145-
child.on("error", (err) =>
146-
logger.error(`Failed to open browser: ${err}. URL: ${launchUrl}`),
147-
);
148-
child.unref();
149-
} catch (err) {
150-
logger.error(`Failed to launch browser: ${err}. URL: ${launchUrl}`);
151+
parsed = new URL(launchUrl);
152+
} catch {
153+
logger.error(`Refusing to surface malformed URL: ${launchUrl}`);
154+
return null;
155+
}
156+
157+
if (
158+
parsed.protocol !== "https:" ||
159+
!/(^|\.)browserstack\.com$/i.test(parsed.hostname)
160+
) {
161+
logger.error(`Refusing to surface untrusted URL: ${launchUrl}`);
162+
return null;
151163
}
164+
165+
const quoted = `"${parsed.toString()}"`;
166+
if (process.platform === "darwin") return `open ${quoted}`;
167+
if (process.platform === "win32") return `cmd /c start "" ${quoted}`;
168+
return `xdg-open ${quoted}`;
152169
}

0 commit comments

Comments
 (0)