From 29f480a57d1179ac12779ea562d050fbef393cfb Mon Sep 17 00:00:00 2001 From: Gaurav Singh Date: Fri, 29 May 2026 00:48:38 +0530 Subject: [PATCH 1/2] fix: validate launchUrl before spawning browser process Restrict openBrowser() to https URLs on *.browserstack.com so attacker-influenced URL data cannot reach `cmd /c start` on Windows or the equivalent open/xdg-open calls on macOS/Linux. Closes the CodeQL js/command-line-injection alert (#2). Co-Authored-By: Claude Sonnet 4.6 --- src/tools/applive-utils/start-session.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index 3f9f4214..ac5f2120 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -127,6 +127,15 @@ export async function startSession( */ function openBrowser(launchUrl: string): void { try { + const parsed = new URL(launchUrl); + if ( + parsed.protocol !== "https:" || + !/(^|\.)browserstack\.com$/i.test(parsed.hostname) + ) { + logger.error(`Refusing to open untrusted URL: ${launchUrl}`); + return; + } + const command = process.platform === "darwin" ? ["open", launchUrl] From 058048aef49d8d8854de234dd5f877098f2e09b7 Mon Sep 17 00:00:00 2001 From: Gaurav Singh Date: Fri, 29 May 2026 01:17:10 +0530 Subject: [PATCH 2/2] fix: return browser-open command instead of spawning it Resolves CodeQL js/command-line-injection (alert #2) at src/tools/applive-utils/start-session.ts. Instead of the server calling childProcess.spawn() with a user-influenced launch URL, it now returns the platform-appropriate open command as text. The host agent prompts the user before executing it, so the server never spawns a process and the command-injection surface is eliminated. The URL is still validated (https + *.browserstack.com allowlist) before being surfaced. Applies the same conversion to the identical openBrowser/spawn in live-utils/start-session.ts for parity. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tools/applive-utils/start-session.ts | 71 +++++++++++------------- src/tools/live-utils/start-session.ts | 65 ++++++++++++++-------- 2 files changed, 74 insertions(+), 62 deletions(-) diff --git a/src/tools/applive-utils/start-session.ts b/src/tools/applive-utils/start-session.ts index ac5f2120..07e958da 100644 --- a/src/tools/applive-utils/start-session.ts +++ b/src/tools/applive-utils/start-session.ts @@ -9,7 +9,6 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { findDeviceByName } from "./device-search.js"; import { pickVersion } from "./version-utils.js"; import { DeviceEntry } from "./types.js"; -import childProcess from "child_process"; import { BrowserStackConfig } from "../../lib/types.js"; import envConfig from "../../config.js"; @@ -114,51 +113,47 @@ export async function startSession( const launchUrl = `https://app-live.browserstack.com/dashboard#${params.toString()}&device=${deviceParam}`; if (!envConfig.REMOTE_MCP) { - openBrowser(launchUrl); + const openCommand = getOpenBrowserCommand(launchUrl); + if (openCommand) { + return [ + `App Live session URL: ${launchUrl}${note}`, + ``, + `To open the session in the default browser, run:`, + ` ${openCommand}`, + ].join("\n"); + } } return launchUrl + note; } /** - * Opens the launch URL in the default browser. - * @param launchUrl - The URL to open. - * @throws Will throw an error if the browser fails to open. + * Returns the platform-appropriate shell command to open `launchUrl` in the + * default browser, or null if the URL is not a trusted BrowserStack URL. + * + * The command is returned to the MCP client so the host agent can prompt the + * user before executing it. The server itself never spawns a process, which + * eliminates the command-injection surface entirely. */ -function openBrowser(launchUrl: string): void { +function getOpenBrowserCommand(launchUrl: string): string | null { + let parsed: URL; try { - const parsed = new URL(launchUrl); - if ( - parsed.protocol !== "https:" || - !/(^|\.)browserstack\.com$/i.test(parsed.hostname) - ) { - logger.error(`Refusing to open untrusted URL: ${launchUrl}`); - return; - } - - const command = - process.platform === "darwin" - ? ["open", launchUrl] - : process.platform === "win32" - ? ["cmd", "/c", "start", launchUrl] - : ["xdg-open", launchUrl]; - - // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process - const child = childProcess.spawn(command[0], command.slice(1), { - stdio: "ignore", - detached: true, - }); - - child.on("error", (error) => { - logger.error( - `Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`, - ); - }); + parsed = new URL(launchUrl); + } catch { + logger.error(`Refusing to surface malformed URL: ${launchUrl}`); + return null; + } - child.unref(); - } catch (error) { - logger.error( - `Failed to open browser automatically: ${error}. Please open this URL manually: ${launchUrl}`, - ); + if ( + parsed.protocol !== "https:" || + !/(^|\.)browserstack\.com$/i.test(parsed.hostname) + ) { + logger.error(`Refusing to surface untrusted URL: ${launchUrl}`); + return null; } + + const quoted = `"${parsed.toString()}"`; + if (process.platform === "darwin") return `open ${quoted}`; + if (process.platform === "win32") return `cmd /c start "" ${quoted}`; + return `xdg-open ${quoted}`; } diff --git a/src/tools/live-utils/start-session.ts b/src/tools/live-utils/start-session.ts index 2fc8500d..2d88cf3d 100644 --- a/src/tools/live-utils/start-session.ts +++ b/src/tools/live-utils/start-session.ts @@ -1,5 +1,4 @@ import logger from "../../logger.js"; -import childProcess from "child_process"; import { filterDesktop } from "./desktop-filter.js"; import { filterMobile } from "./mobile-filter.js"; import { @@ -73,10 +72,21 @@ export async function startBrowserSession( isLocal, ) : buildMobileUrl(args as MobileSearchArgs, entry as MobileEntry, isLocal); + const note = entry.notes ? `, ${entry.notes}` : ""; + if (!envConfig.REMOTE_MCP) { - openBrowser(url); + const openCommand = getOpenBrowserCommand(url); + if (openCommand) { + return [ + `Live session URL: ${url}${note}`, + ``, + `To open the session in the default browser, run:`, + ` ${openCommand}`, + ].join("\n"); + } } - return entry.notes ? `${url}, ${entry.notes}` : url; + + return `${url}${note}`; } function buildDesktopUrl( @@ -125,28 +135,35 @@ function buildMobileUrl( return `https://live.browserstack.com/dashboard#${params.toString()}`; } -// ——— Open a browser window ——— +// ——— Build a browser-open command for the host agent ——— -function openBrowser(launchUrl: string): void { +/** + * Returns the platform-appropriate shell command to open `launchUrl` in the + * default browser, or null if the URL is not a trusted BrowserStack URL. + * + * The command is returned to the MCP client so the host agent can prompt the + * user before executing it. The server itself never spawns a process, which + * eliminates the command-injection surface entirely. + */ +function getOpenBrowserCommand(launchUrl: string): string | null { + let parsed: URL; try { - const command = - process.platform === "darwin" - ? ["open", launchUrl] - : process.platform === "win32" - ? ["cmd", "/c", "start", `""`, `"${launchUrl}"`] - : ["xdg-open", launchUrl]; - - // nosemgrep:javascript.lang.security.detect-child-process.detect-child-process - const child = childProcess.spawn(command[0], command.slice(1), { - stdio: "ignore", - detached: true, - ...(process.platform === "win32" ? { shell: true } : {}), - }); - child.on("error", (err) => - logger.error(`Failed to open browser: ${err}. URL: ${launchUrl}`), - ); - child.unref(); - } catch (err) { - logger.error(`Failed to launch browser: ${err}. URL: ${launchUrl}`); + parsed = new URL(launchUrl); + } catch { + logger.error(`Refusing to surface malformed URL: ${launchUrl}`); + return null; + } + + if ( + parsed.protocol !== "https:" || + !/(^|\.)browserstack\.com$/i.test(parsed.hostname) + ) { + logger.error(`Refusing to surface untrusted URL: ${launchUrl}`); + return null; } + + const quoted = `"${parsed.toString()}"`; + if (process.platform === "darwin") return `open ${quoted}`; + if (process.platform === "win32") return `cmd /c start "" ${quoted}`; + return `xdg-open ${quoted}`; }