[APS-18613] fix: prevent command injection via cypress_config_file in loadJsFile#1080
[APS-18613] fix: prevent command injection via cypress_config_file in loadJsFile#1080avinash-bharti wants to merge 4 commits intomasterfrom
Conversation
…-18613] - Replace execSync() with execFileSync() in loadJsFile() to avoid shell interpolation of user-controlled cypress_config_filepath values - Pass NODE_PATH via env option instead of shell command prefix, which works cross-platform (Unix and Windows) without shell metacharacters - Add validateFilePath() defense-in-depth check that rejects paths containing shell metacharacters before they reach any exec call - Update unit tests to verify execFileSync is called with array args and to confirm command injection payloads are rejected Resolves: APS-18613
| if (DANGEROUS_PATH_CHARS.test(filepath)) { | ||
| throw new Error( | ||
| `Invalid cypress config file path: "${filepath}" contains disallowed characters. ` + | ||
| 'File paths must not include shell metacharacters such as ; " ` $ | & ( ) { } \\' |
There was a problem hiding this comment.
\ is valid for windows right? Not alligned with this.
There was a problem hiding this comment.
Why can't we just check if the path exists or not?
There was a problem hiding this comment.
Good catch — you're right, \ is a valid path separator on Windows. I've removed \ from DANGEROUS_PATH_CHARS in commit 9bb1f6f.
This is safe because the actual security boundary here is execFileSync (no shell invocation), not the regex. Once you spawn node directly without a shell, \ carries no shell meaning anywhere. The regex is purely defense-in-depth against accidental future regressions to execSync/exec. So we only need to reject characters that have shell meaning on Unix or Windows shells (; " $ | & ( ) { }`) — not legitimate path separators.
I added Windows-path positive tests to lock this in:
C:\Users\test\cypress.config.js✓C:\Program Files\my app\cypress.config.js✓ (spaces + backslashes).\subdir\cypress.config.js✓\\server\share\cypress.config.js✓ (UNC)
Validated end-to-end with a real BrowserStack production session on Windows 11: build f5ca218e318ca84b90189b55440fef7edad300b5 — 1/1 passed.
There was a problem hiding this comment.
Existence-checking by itself is not a security control here — it would tell us if the file currently exists, but it wouldn't stop a path like cypress.config.js"; rm -rf ~ from being interpolated into a shell command. The attacker doesn't need the literal file to exist for the injected suffix (; rm -rf ~) to run — once the string reaches execSync, the shell parses it regardless of whether cypress.config.js" is a real file.
Demonstrated this experimentally on master (commit 9bb1f6f workspace): with the master tarball, payload nonexistent.config.js"; touch /tmp/aps18613-master-pwn-<ts>; echo " created the marker file even though nonexistent.config.js doesn't exist. With the fix tarball, the same payload was rejected by validateFilePath before reaching execFileSync — no marker file created.
That said — existsSync IS valuable as a UX improvement (clearer error message), so I added it in 9bb1f6f inside loadJsFile, after validateFilePath and before execFileSync. It now throws Cypress config file not found at: <path> if the file is missing, with an inline comment documenting that this is for UX, not security. The security guarantees remain execFileSync (no shell) + the metacharacter regex.
| }; | ||
| const args = [require_module_helper_path, cypress_config_filepath]; | ||
|
|
||
| logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`); |
There was a problem hiding this comment.
How are we setting env vars?
There was a problem hiding this comment.
Env vars are now passed through execFileSync's env option instead of the old shell-prefix approach (NODE_PATH=... node ... on Unix, set NODE_PATH=...&& on Windows). This is the safer cross-platform path — no shell interpolation needed, and the child process gets a clean inherited environment with NODE_PATH overridden:
const execOptions = {
env: Object.assign({}, process.env, { NODE_PATH: bstack_node_modules_path })
};
const args = [require_module_helper_path, cypress_config_filepath];
cp.execFileSync('node', args, execOptions);Verified end-to-end with a shim that prints process.env.NODE_PATH from the child: when called with bstack_node_modules_path = '/expected/test/path/aps-18613-marker', the child's process.env.NODE_PATH is exactly that value. Process tree on macOS confirms only node is spawned (no sh/bash/cmd/powershell):
2594 2573 node -e ...loadJsFile(path.resolve('cypress.config.js'), '/test/np')
2656 2594 node /.../requireModule.js /.../cypress.config.js
So the parent CLI process spawns node directly, which then receives the env via the inherited environment — equivalent semantically to the old shell-prefix but without any shell parsing surface area.
There was a problem hiding this comment.
Don't want to modify the existing logic, just validation should suffice.
There was a problem hiding this comment.
Wanted to lay out the rationale before reverting, because this isn't a stylistic refactor — it's the load-bearing security control:
execFileSync is the actual fix; the regex is hardening. In our extended Windows validation (#issuecomment-4333011662 → E10), we commented out validateFilePath() entirely and replayed the HackerOne payload on a real Win11 host. execFileSync('node', [args]) blocked the RCE (returned MODULE_NOT_FOUND, no powershell.exe spawn) because no shell parses the args. With regex-only validation, any future bypass — Unicode normalization, a missed metachar, an encoding edge case — re-opens the RCE path.
The change is minimal and semantics-preserving:
- cp.execSync(`NODE_PATH="${p}" node "${helper}" "${cfg}"`) // Unix
- cp.execSync(`set NODE_PATH=${p}&& node "${helper}" "${cfg}"`) // Windows
+ cp.execFileSync('node', [helper, cfg], { env: {...process.env, NODE_PATH: p} })Same node child, same NODE_PATH override — no shell, no platform branch.
Confidence — what we've validated directly on dev-test-cypress-003-ec2-euc1a-stag.bsstag.com (real Win11 release host) using a Windows-built tarball running prod BrowserStack sessions:
- 4/4 path-acceptance BS prod builds passed (relative, absolute-with-backslashes, path-with-spaces,
.\path) — verified viabs_api_build_sessions - 6/6 metachar payloads blocked + H1 PoC blocked
- Process tree: only
node→node(nocmd.exe/powershell.exechild) - NODE_PATH propagation correct via
envoption (verified with shim) - Unit suites Δ on Windows: fix 666p/25f vs master 650p/26f → +16 pass / -1 fail, 0 fix-only failures
cy.task/cy.execsmoke session:done~156s
Where my confidence isn't 100%:
- E2 (full TypeScript config E2E flow) — the build was created on BS but the runner went down before final session-status capture; partial evidence only
- We did not run the team's standard QA
AutomateFrameworksTestsregression that's part of the Cypress release process (Stage 5 in the release doc)
Suggested path: run this PR through the QA AutomateFrameworksTests job (with CYPRESS_TEST_SUITE_TYPE=cypressJS_10_above, CYPRESS_SPECS=basic_spec) before merge, on at least one Mac + one Windows terminal — that gives us the regression-grade signal the release process expects. Happy to coordinate with QA on this.
If after the regression you still prefer regex-only, I'll revert to execSync — but the regression run would let us make the call with full data instead of trading away defense-in-depth on a hunch. Picking up your other comment (input validation on all params) in a follow-up PR linked to APS-18613.
…X [APS-18613] Address review feedback on PR #1080: - Remove backslash (\) from DANGEROUS_PATH_CHARS regex so legitimate Windows absolute/relative paths (C:\Users\..., .\subdir\..., \\server\share\...) are no longer rejected. Backslash is a path separator on Windows, not a shell metacharacter — and the actual security boundary is execFileSync (no shell invocation), not the regex. - Add an fs.existsSync() check inside loadJsFile() that throws a clear "Cypress config file not found at: <path>" error before invoking execFileSync. This is purely a UX improvement — existsSync alone would NOT prevent injection; the metacharacter regex + execFileSync remain the security guarantees. - Update unit tests: * Add positive tests for Windows-style absolute, Program-Files (with spaces), relative (.\subdir\...) and UNC (\\server\share\...) paths * Add a positive test in loadJsFile that exercises the same Windows paths end-to-end without throwing * Add a test for the new file-not-found path that confirms execFileSync is NOT invoked when the file is missing * Update existsSync call-count assertion from calledOnce to calledTwice (UX check + cleanup unlink) Resolves: APS-18613
…nd-injection-cypress-config
Windows Validation — End-to-End Exploit ReplayValidated on the real Windows release host: Path acceptance on FIX (4/4 passed)
All four hit BrowserStack Hub successfully (Windows 11 Chrome 136). REST API confirms Master vs Fix exploit comparison
Process tree on FIX (Step 5, no-shell verification)
NODE_PATH propagation on FIX (Step 6)Captured by patching {
"pid": 2636, "ppid": 828,
"NODE_PATH": "C:\\Users\\Administrator\\aps-18613\\sample-fix\\tmpBstackPackages\\node_modules",
"argv": ["...node.exe", "...\\requireModule.js", "...\\cypress.config.js"]
}
Unit suites on Windows (no regression)Same Mocha/Chai/Sinon test suite run on both branches via
The 25 failing tests on FIX are pre-existing failures inherited from master (not introduced by the patch). The 16 new passing tests come from the new Verdict
cc @karanshah-browserstack — fix validates end-to-end. Linked Jira: APS-18613. |
Extended Windows validation — final passReattach to the prior monitor failed (Windows runner Process-tree on FIX (Win11) — node-only childrenE10 verdict — defense-in-depth proof (CRITICAL)With
→ Build IDs from extended passAllow-path BS builds on FIX (E5):
TypeScript run (E2): build Outstanding (host-down only, not security)
Ready for re-review by @karanshah-browserstack. Full E-step table is on Jira APS-18613. |
There was a problem hiding this comment.
Copilot wasn't able to review any files in this pull request.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }; | ||
| const args = [require_module_helper_path, cypress_config_filepath]; | ||
|
|
||
| logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`); |
There was a problem hiding this comment.
Don't want to modify the existing logic, just validation should suffice.
| // Note: backslash (\) is intentionally NOT included here because it is a | ||
| // legitimate path separator on Windows (e.g. C:\Users\me\cypress.config.js). | ||
| // The actual security boundary is execFileSync (no shell), not this regex. | ||
| const DANGEROUS_PATH_CHARS = /[;"`$|&(){}]/; |
There was a problem hiding this comment.
Also, the ask was to do input validation on all the parameters which we take input on. Can we ask claude to check and implement the same?
There was a problem hiding this comment.
Good call — this should be a CLI-wide sweep, not just cypress_config_file. Doing it in this PR would balloon scope and delay the H1 fix, so opening a follow-up ticket linked to APS-18613.
Notable parallel vector I'll prioritize first: convertTsConfig in this same file still uses cp.execSync(tscCommand) with bstack_node_modules_path interpolated as a shell prefix — literal same-shape bug as the loadJsFile one. Will fix that + audit the rest of the CLI for similar patterns.
Follow-up Jira: APS-18981 — Input validation sweep across browserstack-cypress-cli.
Security Fix: APS-18613
Issue
The
browserstack-cypress-clinpm package contains an OS command injection vulnerability (RCE) in thecypress_config_fileconfiguration parameter. The vulnerability exists inreadCypressConfigUtil.jswhere theloadJsFile()function constructs a shell command by directly interpolating the user-controlledcypress_config_filepathvalue into a template literal and executes it viachild_process.execSync().An attacker can inject shell metacharacters (
;,",&) in the config path withinbrowserstack.jsonto break out of the quoted argument and execute arbitrary commands. This was reported via HackerOne (Report #3610018).Root Cause
The
loadJsFile()function usedcp.execSync()which invokes a system shell to execute the command. Becausecypress_config_filepath(sourced frombrowserstack.json) was interpolated directly into the command string without sanitization, shell metacharacters in the path were interpreted by the shell, enabling arbitrary command execution.Vulnerable code:
Fix Applied
Two layers of defense:
Primary fix --
execFileSyncinstead ofexecSync: Replacedcp.execSync(load_command)withcp.execFileSync('node', [args], { env }).execFileSyncspawns the process directly WITHOUT invoking a shell, so user-controlled values cannot break out into shell commands.NODE_PATHis passed via theenvoption instead of a shell prefix, which works cross-platform (Unix and Windows) without needing platform-specific branching.Defense-in-depth --
validateFilePath(): Added input validation that rejects file paths containing shell metacharacters (;,\",`,$,|,&,(,),{,},\\) before they reach any execution call. This guards against regression ifexecFileSyncis ever replaced with a shell-based exec in the future.Files Changed
bin/helpers/readCypressConfigUtil.js-- Security fix inloadJsFile()+ newvalidateFilePath()functiontest/unit/bin/helpers/readCypressConfigUtil.js-- Updated tests forexecFileSync, addedvalidateFilePathtests, added command injection rejection testsTesting
execFileSyncis called with array arguments (not string command)validateFilePath()covering: normal paths, paths with spaces, semicolon injection (Linux/macOS), ampersand injection (Windows PowerShell), backtick injection, dollar-sign injection, pipe injectionloadJsFile()rejects malicious file paths from the HackerOne reportnpm testsuite execution requires local environment setup (Bash access was restricted during automated remediation). Reviewer should runnpm testto confirm no regressions.BrowserStack Session Sanity (mandatory for session repos):
browserstack-cypress-cli runcommand using a normalbrowserstack.jsonto confirm config loading still worksJira Ticket
APS-18613
Checklist