Skip to content

Commit e578fad

Browse files
security: validate override/response URLs, config path, JWT; warn on proxy
Ships the low-blast-radius subset of the CLI critical findings. APS-19010 — env-var API redirect: only honour BSTACK_CYPRESS_NODE_ENV url overrides (RAILS_HOST/UPLOAD_URL/DASHBOARD_URL/USAGE_REPORTING_URL) when they point at *.browserstack.com / *.bsstag.com / localhost; otherwise warn and fall back to the production defaults. APS-19011 — validate the API-supplied upload_url host before using it for the tests.zip upload; warn (do NOT cert-pin) when an HTTP(S) proxy routes all API traffic incl. credentials; structural-only JWT check on the TestHub token (defence-in-depth — the CLI has no key to verify the signature). APS-19008 (browserstack.json half) — read browserstack.json via JSON.parse(fs.readFileSync) instead of require() so a .js config cannot execute arbitrary code; require a .json extension and project-root path containment. New bin/helpers/securityValidation.js (stdlib-only): isAllowedBrowserstackUrl, isPathInsideBase, isWellFormedJwt, covered by test/unit/.../securityValidation.js (13 tests, both paths). Deliberately NOT changed (accepted-risk / opt-in — needs product decision): - cypress.config.js is legitimately JS that imports plugins; NOT sandboxed by default (a vm sandbox breaks real configs). APS-19008 cypress-config half. - npm_dependencies install still runs lifecycle scripts (APS-19009): NOT fixed here. Note: PR #1128's repo .npmrc (ignore-scripts=true) does NOT protect end users — packageInstaller copies the *user's* .npmrc into the temp install dir, not the CLI's. APS-19009's real fix (--ignore-scripts + package-name/version validation + shell:false) remains OPEN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bbcb4bc commit e578fad

7 files changed

Lines changed: 260 additions & 14 deletions

File tree

bin/helpers/config.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
var config = require('./config.json');
2+
const { isAllowedBrowserstackUrl } = require('./securityValidation');
23

34
config.env = process.env.BSTACK_CYPRESS_NODE_ENV || "production";
45

6+
// Only honour an env-var URL override if it points at a BrowserStack
7+
// (prod/staging) host or localhost. Without this allowlist an attacker who can
8+
// set CI env vars (BSTACK_CYPRESS_NODE_ENV + RAILS_HOST/UPLOAD_URL/...) could
9+
// redirect all API calls — including Basic Auth credentials and the tests.zip
10+
// upload — to their own server (APS-19010). Invalid overrides fall back to the
11+
// production defaults from config.json.
12+
const applyUrlOverride = (envValue, currentValue, label) => {
13+
if (envValue === undefined || envValue === null || envValue === "") {
14+
return currentValue;
15+
}
16+
if (isAllowedBrowserstackUrl(envValue)) {
17+
return envValue;
18+
}
19+
// eslint-disable-next-line no-console
20+
console.warn(`Ignoring ${label} override "${envValue}": only *.browserstack.com, *.bsstag.com or localhost URLs are allowed.`);
21+
return currentValue;
22+
};
23+
524
if(config.env !== "production") {
625
// load config based on env
726
require('custom-env').env(config.env);
827

9-
config.uploadUrl = process.env.UPLOAD_URL;
10-
config.rails_host = process.env.RAILS_HOST;
11-
config.dashboardUrl = process.env.DASHBOARD_URL;
12-
config.usageReportingUrl = process.env.USAGE_REPORTING_URL;
28+
config.uploadUrl = applyUrlOverride(process.env.UPLOAD_URL, config.uploadUrl, "UPLOAD_URL");
29+
config.rails_host = applyUrlOverride(process.env.RAILS_HOST, config.rails_host, "RAILS_HOST");
30+
config.dashboardUrl = applyUrlOverride(process.env.DASHBOARD_URL, config.dashboardUrl, "DASHBOARD_URL");
31+
config.usageReportingUrl = applyUrlOverride(process.env.USAGE_REPORTING_URL, config.usageReportingUrl, "USAGE_REPORTING_URL");
1332
}
1433

1534
config.cypress_v1 = `${config.rails_host}/automate/cypress/v1`;

bin/helpers/getInitialDetails.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const logger = require('./logger').winstonLogger,
66
Constants = require('./constants');
77

88
const { setAxiosProxy } = require('./helper');
9+
const { isAllowedBrowserstackUrl } = require('./securityValidation');
910

1011
exports.getInitialDetails = (bsConfig, args, rawArgs) => {
1112
return new Promise(async (resolve, reject) => {
@@ -40,7 +41,16 @@ exports.getInitialDetails = (bsConfig, args, rawArgs) => {
4041
resolve({});
4142
} else {
4243
if (!utils.isUndefined(responseData.grr) && responseData.grr.enabled && !utils.isUndefined(responseData.grr.urls)) {
43-
config.uploadUrl = responseData.grr.urls.upload_url;
44+
// Validate the API-supplied upload_url before trusting it: a MITM /
45+
// proxy could rewrite it to redirect the tests.zip upload to an
46+
// attacker host (APS-19011). Only accept BrowserStack hosts; otherwise
47+
// keep the default uploadUrl.
48+
const grrUploadUrl = responseData.grr.urls.upload_url;
49+
if (isAllowedBrowserstackUrl(grrUploadUrl)) {
50+
config.uploadUrl = grrUploadUrl;
51+
} else {
52+
logger.warn(`Ignoring upload_url from API response (not a BrowserStack host): ${grrUploadUrl}`);
53+
}
4454
}
4555
resolve(responseData);
4656
}

bin/helpers/helper.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,11 @@ exports.truncateString = (field, truncateSizeInBytes) => {
457457
exports.setAxiosProxy = (axiosConfig) => {
458458
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY) {
459459
const httpProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY
460+
// Warn that all API traffic (including Basic Auth credentials) is being
461+
// routed through this proxy, which can read/rewrite it if it terminates TLS
462+
// (APS-19011). We honour the proxy (corporate CIs need it) but no longer do
463+
// so silently.
464+
logger.warn(`An HTTP(S) proxy is configured (${httpProxy}); all BrowserStack API traffic, including credentials, will be routed through it.`);
460465
axiosConfig.proxy = false;
461466
axiosConfig.httpsAgent = new HttpsProxyAgent(httpProxy);
462467
};

bin/helpers/securityValidation.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
const path = require('path');
4+
5+
/**
6+
* Security validation helpers shared across the CLI.
7+
*
8+
* These guard the "untrusted edges" of the CLI:
9+
* - override/response URLs that could redirect API traffic or uploads
10+
* (APS-19010, APS-19011)
11+
* - config-file paths that could escape the project directory (APS-19008)
12+
*
13+
* Kept dependency-free (stdlib only) so the logic can be unit tested without
14+
* pulling in the CLI's network/config stack.
15+
*/
16+
17+
// Hosts the CLI is allowed to talk to for API / upload endpoints. Covers
18+
// production, staging (bsstag.com) and local development. Anything else is
19+
// treated as attacker-controlled and rejected.
20+
const ALLOWED_HOST_SUFFIXES = ['.browserstack.com', '.bsstag.com'];
21+
const ALLOWED_EXACT_HOSTS = ['browserstack.com', 'bsstag.com', 'localhost', '127.0.0.1', '::1'];
22+
23+
/**
24+
* Returns true if the given URL points at a BrowserStack (prod/staging) host or
25+
* localhost. Only http/https are accepted. Any parse failure returns false
26+
* (fail-closed).
27+
* @param {string} urlString
28+
* @returns {boolean}
29+
*/
30+
function isAllowedBrowserstackUrl(urlString) {
31+
if (typeof urlString !== 'string' || urlString.trim() === '') {
32+
return false;
33+
}
34+
let parsed;
35+
try {
36+
parsed = new URL(urlString);
37+
} catch (e) {
38+
return false;
39+
}
40+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
41+
return false;
42+
}
43+
const host = parsed.hostname.toLowerCase();
44+
if (ALLOWED_EXACT_HOSTS.includes(host)) {
45+
return true;
46+
}
47+
return ALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix));
48+
}
49+
50+
/**
51+
* Resolves a candidate path and asserts it stays inside baseDir. Used to stop
52+
* config-file path traversal (e.g. --config-file ../../outside/browserstack.json).
53+
* @param {string} candidatePath
54+
* @param {string} baseDir defaults to process.cwd()
55+
* @returns {boolean}
56+
*/
57+
function isPathInsideBase(candidatePath, baseDir) {
58+
if (typeof candidatePath !== 'string' || candidatePath === '') {
59+
return false;
60+
}
61+
const base = path.resolve(baseDir || process.cwd());
62+
const resolved = path.resolve(base, candidatePath);
63+
// Must be the base itself or a descendant (base + separator prefix).
64+
return resolved === base || resolved.startsWith(base + path.sep);
65+
}
66+
67+
/**
68+
* Structural (NOT cryptographic) validation of a JWT: three non-empty
69+
* base64url segments. The CLI is not the token issuer and has no key to verify
70+
* the signature, so this only rejects obviously-malformed / MITM-swapped
71+
* garbage tokens. Defence-in-depth, not an integrity guarantee.
72+
* @param {string} token
73+
* @returns {boolean}
74+
*/
75+
function isWellFormedJwt(token) {
76+
if (typeof token !== 'string') {
77+
return false;
78+
}
79+
const parts = token.split('.');
80+
if (parts.length !== 3) {
81+
return false;
82+
}
83+
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p));
84+
}
85+
86+
module.exports = {
87+
isAllowedBrowserstackUrl,
88+
isPathInsideBase,
89+
isWellFormedJwt,
90+
ALLOWED_HOST_SUFFIXES,
91+
ALLOWED_EXACT_HOSTS,
92+
};

bin/helpers/utils.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const readdir = promisify(fs.readdir);
1313
const stat = promisify(fs.stat);
1414
const TIMEZONE = require("../helpers/timezone.json");
1515
const { setAxiosProxy } = require('./helper');
16+
const { isPathInsideBase } = require('./securityValidation');
1617

1718
const usageReporting = require("./usageReporting"),
1819
logger = require("./logger").winstonLogger,
@@ -33,17 +34,30 @@ exports.validateBstackJson = (bsConfigPath) => {
3334
return new Promise(function (resolve, reject) {
3435
try {
3536
logger.info(`Reading config from ${bsConfigPath}`);
36-
let bsConfig = require(bsConfigPath);
37+
// browserstack.json is a pure-JSON config, so parse it as data rather than
38+
// require()-ing it (require executes any JS the file contains — a
39+
// PR-supplied .js config would run arbitrary code, APS-19008). Also require
40+
// a .json extension and that the file resolves inside the project root so a
41+
// crafted --config-file cannot point outside the project or at a script.
42+
const resolvedPath = path.resolve(bsConfigPath);
43+
if (path.extname(resolvedPath).toLowerCase() !== ".json") {
44+
return reject(`Invalid browserstack.json file. Error : config file must be a .json file.`);
45+
}
46+
if (!isPathInsideBase(resolvedPath, process.cwd())) {
47+
return reject(`Invalid browserstack.json file. Error : config file must be inside the project directory.`);
48+
}
49+
if (!fs.existsSync(resolvedPath)) {
50+
return reject(
51+
"Couldn't find the browserstack.json file at \"" +
52+
bsConfigPath +
53+
'". Please use --config-file <path to browserstack.json>.'
54+
);
55+
}
56+
let bsConfig = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
3757
bsConfig = exports.normalizeTestReportingConfig(bsConfig);
3858
resolve(bsConfig);
3959
} catch (e) {
40-
reject(
41-
e.code === "MODULE_NOT_FOUND"
42-
? "Couldn't find the browserstack.json file at \"" +
43-
bsConfigPath +
44-
'". Please use --config-file <path to browserstack.json>.'
45-
: `Invalid browserstack.json file. Error : ${e.message}`
46-
);
60+
reject(`Invalid browserstack.json file. Error : ${e.message}`);
4761
}
4862
});
4963
};

bin/testhub/utils.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const logger = require("../../bin/helpers/logger").winstonLogger;
44
const TESTHUB_CONSTANTS = require("./constants");
55
const testObservabilityHelper = require("../../bin/testObservability/helper/helper");
66
const helper = require("../helpers/helper");
7+
const { isWellFormedJwt } = require("../helpers/securityValidation");
78
const accessibilityHelper = require("../accessibility-automation/helper");
89
const detectPort = require('detect-port');
910

@@ -232,7 +233,15 @@ exports.findAvailablePort = async (preferredPort, maxAttempts = 10) => {
232233
}
233234

234235
exports.setTestHubCommonMetaInfo = (user_config, responseData) => {
235-
process.env.BROWSERSTACK_TESTHUB_JWT = responseData.jwt;
236+
// Structural (not cryptographic) sanity check on the JWT from the API
237+
// response. The CLI has no key to verify the signature, so this only rejects
238+
// obviously-malformed / MITM-swapped garbage tokens — defence-in-depth, not
239+
// an integrity guarantee (APS-19011).
240+
if (responseData && responseData.jwt !== undefined && !isWellFormedJwt(responseData.jwt)) {
241+
logger.warn('Received a malformed TestHub JWT from the API response; ignoring it.');
242+
} else {
243+
process.env.BROWSERSTACK_TESTHUB_JWT = responseData.jwt;
244+
}
236245
process.env.BROWSERSTACK_TESTHUB_UUID = responseData.build_hashed_id;
237246
user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TESTHUB_JWT`);
238247
user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TESTHUB_UUID`);
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict';
2+
const path = require('path');
3+
const { expect } = require('chai');
4+
5+
const {
6+
isAllowedBrowserstackUrl,
7+
isPathInsideBase,
8+
isWellFormedJwt,
9+
} = require('../../../../bin/helpers/securityValidation');
10+
11+
describe('securityValidation', () => {
12+
describe('isAllowedBrowserstackUrl', () => {
13+
it('accepts BrowserStack production and staging hosts', () => {
14+
expect(isAllowedBrowserstackUrl('https://api.browserstack.com')).to.be.true;
15+
expect(isAllowedBrowserstackUrl('https://api-cloud.browserstack.com/automate-frameworks/cypress/upload')).to.be.true;
16+
expect(isAllowedBrowserstackUrl('https://staging.bsstag.com')).to.be.true;
17+
expect(isAllowedBrowserstackUrl('https://browserstack.com')).to.be.true;
18+
});
19+
20+
it('accepts localhost for local development', () => {
21+
expect(isAllowedBrowserstackUrl('http://localhost:3000')).to.be.true;
22+
expect(isAllowedBrowserstackUrl('http://127.0.0.1:8080')).to.be.true;
23+
});
24+
25+
it('rejects arbitrary attacker hosts', () => {
26+
expect(isAllowedBrowserstackUrl('https://attacker.example')).to.be.false;
27+
expect(isAllowedBrowserstackUrl('https://evil.com')).to.be.false;
28+
});
29+
30+
it('rejects look-alike / suffix-spoofing hosts', () => {
31+
// Not a real subdomain of browserstack.com — endsWith check uses a
32+
// leading dot so this must be rejected.
33+
expect(isAllowedBrowserstackUrl('https://browserstack.com.attacker.net')).to.be.false;
34+
expect(isAllowedBrowserstackUrl('https://notbrowserstack.com')).to.be.false;
35+
expect(isAllowedBrowserstackUrl('https://evilbrowserstack.com')).to.be.false;
36+
});
37+
38+
it('rejects non-http(s) schemes and malformed input', () => {
39+
expect(isAllowedBrowserstackUrl('file:///etc/passwd')).to.be.false;
40+
expect(isAllowedBrowserstackUrl('ftp://api.browserstack.com')).to.be.false;
41+
expect(isAllowedBrowserstackUrl('not a url')).to.be.false;
42+
expect(isAllowedBrowserstackUrl('')).to.be.false;
43+
expect(isAllowedBrowserstackUrl(undefined)).to.be.false;
44+
expect(isAllowedBrowserstackUrl(null)).to.be.false;
45+
});
46+
});
47+
48+
describe('isPathInsideBase', () => {
49+
const base = path.resolve('/tmp/project');
50+
51+
it('accepts paths inside the base directory', () => {
52+
expect(isPathInsideBase('browserstack.json', base)).to.be.true;
53+
expect(isPathInsideBase('sub/dir/browserstack.json', base)).to.be.true;
54+
expect(isPathInsideBase(path.join(base, 'browserstack.json'), base)).to.be.true;
55+
});
56+
57+
it('accepts the base directory itself', () => {
58+
expect(isPathInsideBase(base, base)).to.be.true;
59+
});
60+
61+
it('rejects path traversal outside the base directory', () => {
62+
expect(isPathInsideBase('../../etc/passwd', base)).to.be.false;
63+
expect(isPathInsideBase('../outside/browserstack.json', base)).to.be.false;
64+
expect(isPathInsideBase('/etc/passwd', base)).to.be.false;
65+
});
66+
67+
it('rejects a sibling directory that shares a name prefix', () => {
68+
// /tmp/project-evil must not be treated as inside /tmp/project.
69+
expect(isPathInsideBase('/tmp/project-evil/x.json', base)).to.be.false;
70+
});
71+
72+
it('rejects empty / non-string input', () => {
73+
expect(isPathInsideBase('', base)).to.be.false;
74+
expect(isPathInsideBase(undefined, base)).to.be.false;
75+
});
76+
});
77+
78+
describe('isWellFormedJwt', () => {
79+
it('accepts a structurally valid three-part token', () => {
80+
expect(isWellFormedJwt('aaa.bbb.ccc')).to.be.true;
81+
expect(isWellFormedJwt('eyJhbGci.eyJzdWIi.SflKxwRJ-abc_123')).to.be.true;
82+
});
83+
84+
it('rejects tokens without exactly three parts', () => {
85+
expect(isWellFormedJwt('aaa.bbb')).to.be.false;
86+
expect(isWellFormedJwt('aaa.bbb.ccc.ddd')).to.be.false;
87+
expect(isWellFormedJwt('notajwt')).to.be.false;
88+
});
89+
90+
it('rejects tokens with empty or invalid segments', () => {
91+
expect(isWellFormedJwt('aaa..ccc')).to.be.false;
92+
expect(isWellFormedJwt('aaa.b b.ccc')).to.be.false;
93+
expect(isWellFormedJwt('')).to.be.false;
94+
expect(isWellFormedJwt(undefined)).to.be.false;
95+
});
96+
});
97+
});

0 commit comments

Comments
 (0)