Skip to content

Commit 119409b

Browse files
Merge pull request #184 from browserstack/release_1.5.14
Release 1.5.14
2 parents 536bb6f + 0d54af8 commit 119409b

7 files changed

Lines changed: 308 additions & 41 deletions

File tree

.github/workflows/Semgrep.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ jobs:
2727

2828
container:
2929
# A Docker image with Semgrep installed. Do not change this.
30-
image: returntocorp/semgrep:1.166.0
30+
# Pinned to an immutable digest so a mutated tag cannot redirect CI to a
31+
# different image. Refresh with:
32+
# docker manifest inspect returntocorp/semgrep:<tag>
33+
image: returntocorp/semgrep:1.166.0@sha256:c180f0c93a17b420c0af5006214a29d3c747c5459c732b740191adf657dd0068
3134
# Skip any PR created by dependabot to avoid permission issues:
3235
if: (github.actor != 'dependabot[bot]')
3336

lib/Local.js

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ function Local(){
2323
this.logfile = this.sanitizePath(path.join(process.cwd(), 'local.log'));
2424
this.opcode = 'start';
2525
this.exitCallback;
26+
/*
27+
* Binary-download fallback signalling, scoped to THIS Local instance. Replaces
28+
* the former process.env.BINARY_DOWNLOAD_* globals, which bled retry/fallback
29+
* state (and the cached source URL) across every concurrent Local instance in
30+
* the process and let a pre-set env var steer the download to an arbitrary
31+
* host. This single object is shared with each LocalBinary the retry loop
32+
* creates, so the fallback URL is still cached across retries of THIS instance
33+
* only.
34+
*/
35+
this.binaryDownloadState = { fallbackEnabled: false, errorMessage: null, sourceURL: null };
2636

2737
this.errorRegex = /\*\*\* Error: [^\r\n]*/i;
2838
this.doneRegex = /Press Ctrl-C to exit/i;
@@ -57,7 +67,7 @@ function Local(){
5767
else
5868
return new LocalError('No output received');
5969
if(data['state'] != 'connected'){
60-
return new LocalError(data['message']['message']);
70+
return new LocalError(that.getErrorMessage(data));
6171
} else {
6272
that.pid = data['pid'];
6373
that.isProcessRunning = true;
@@ -71,8 +81,8 @@ function Local(){
7181
that.retriesLeft -= 1;
7282
fs.unlinkSync(that.binaryPath);
7383
delete(that.binaryPath);
74-
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
75-
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
84+
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
85+
that.binaryDownloadState.fallbackEnabled = true;
7686
return that.startSync(options);
7787
} else {
7888
throw new LocalError(error.toString());
@@ -106,25 +116,31 @@ function Local(){
106116
that.retriesLeft -= 1;
107117
fs.unlinkSync(that.binaryPath);
108118
delete(that.binaryPath);
109-
process.env.BINARY_DOWNLOAD_ERROR_MESSAGE = binaryDownloadErrorMessage;
110-
process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED = true;
119+
that.binaryDownloadState.errorMessage = binaryDownloadErrorMessage;
120+
that.binaryDownloadState.fallbackEnabled = true;
111121
that.start(options, callback);
112122
return;
113123
} else {
114124
callback(new LocalError(error.toString()));
125+
return;
115126
}
116127
}
117128

118129
var data = {};
119-
if(stdout)
120-
data = JSON.parse(stdout);
121-
else if(stderr)
122-
data = JSON.parse(stderr);
123-
else
130+
var output = stdout || stderr;
131+
if(!output) {
124132
callback(new LocalError('No output received'));
133+
return;
134+
}
135+
try {
136+
data = JSON.parse(output);
137+
} catch(parseError) {
138+
callback(new LocalError('Invalid output received: ' + parseError.message, output));
139+
return;
140+
}
125141

126142
if(data['state'] != 'connected'){
127-
callback(new LocalError(data['message']['message']));
143+
callback(new LocalError(that.getErrorMessage(data)));
128144
} else {
129145
that.pid = data['pid'];
130146
that.isProcessRunning = true;
@@ -134,6 +150,17 @@ function Local(){
134150
}, options['bs-host']);
135151
};
136152

153+
// The binary reports failures as {"state": "...", "message": {"message": "..."}},
154+
// but not every non-connected payload carries a message key. Dereferencing it
155+
// blindly throws, and inside the execFile callback that throw is an
156+
// uncaughtException the caller cannot catch. See LOC-7325.
157+
this.getErrorMessage = function(data){
158+
var message = data && data['message'];
159+
if(message && typeof message === 'object')
160+
message = message['message'];
161+
return message || 'Failed to start BrowserStack Local';
162+
};
163+
137164
this.isRunning = function(){
138165
return this.pid && running(this.pid) && this.isProcessRunning;
139166
};
@@ -260,6 +287,10 @@ function Local(){
260287
this.getBinaryPath = function(callback, bsHost){
261288
if(typeof(this.binaryPath) == 'undefined'){
262289
this.binary = new LocalBinary();
290+
/* Share THIS instance's download-fallback state so it survives across the
291+
* LocalBinary objects recreated during the retry loop, without ever
292+
* touching process-global state. */
293+
this.binary.downloadState = this.binaryDownloadState;
263294
var conf = {};
264295
if(this.proxyHost && this.proxyPort){
265296
conf.proxyHost = this.proxyHost;

lib/LocalBinary.js

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,38 @@ function LocalBinary(){
2020
this.baseRetries = 9;
2121
this.sourceURL = null;
2222
this.downloadErrorMessage = null;
23+
/*
24+
* Per-instance binary-download signalling. Historically these three fields were
25+
* carried on process.env (BINARY_DOWNLOAD_FALLBACK_ENABLED / _ERROR_MESSAGE /
26+
* _SOURCE_URL), which is a process-global mutable store: a failure on one Local
27+
* instance bled into every other instance in the same process, and an attacker
28+
* who could set the env before boot could force this instance to download from
29+
* an arbitrary host. Keep the state on the instance instead. The owning Local
30+
* object shares ONE downloadState object across the LocalBinary instances it
31+
* recreates during a retry loop, so the fallback URL is still cached within a
32+
* single Local instance without leaking across sibling instances.
33+
*/
34+
this.downloadState = { fallbackEnabled: false, errorMessage: null, sourceURL: null };
2335

2436
this.getSourceUrlSync = function(conf, retries) {
2537
/* Request for an endpoint to download the local binary from Rails no more than twice with 5 retries each */
2638
if (![4, 9].includes(retries) && this.sourceURL != null) {
2739
return this.sourceURL;
2840
}
2941

30-
if (process.env.BINARY_DOWNLOAD_SOURCE_URL !== undefined && process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries != 4) {
42+
if (this.downloadState.sourceURL != null && this.downloadState.fallbackEnabled && this.parentRetries != 4) {
3143
/* This is triggered from Local.js if there's an error executing the downloaded binary */
32-
return process.env.BINARY_DOWNLOAD_SOURCE_URL;
44+
return this.downloadState.sourceURL;
3345
}
3446

3547
let cmd, opts;
3648
cmd = 'node';
37-
opts = [path.join(__dirname, 'fetchDownloadSourceUrl.js'), this.key, this.bsHost];
49+
/* The auth token is handed to the child through its environment, not argv —
50+
argv is readable by any local user via `ps` / /proc/<pid>/cmdline. */
51+
opts = [path.join(__dirname, 'fetchDownloadSourceUrl.js'), this.bsHost];
3852

39-
if (retries == 4 || (process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries == 4)) {
40-
opts.push(true, this.downloadErrorMessage || process.env.BINARY_DOWNLOAD_ERROR_MESSAGE);
53+
if (retries == 4 || (this.downloadState.fallbackEnabled && this.parentRetries == 4)) {
54+
opts.push(true, this.downloadErrorMessage || this.downloadState.errorMessage);
4155
} else {
4256
opts.push(false, null);
4357
}
@@ -53,10 +67,13 @@ function LocalBinary(){
5367

5468
const userAgent = [packageName, version].join('/');
5569
const env = Object.assign({ 'USER_AGENT': userAgent }, process.env);
70+
if (this.key) {
71+
env.BROWSERSTACK_LOCAL_AUTH_TOKEN = this.key;
72+
}
5673
const obj = childProcess.spawnSync(cmd, opts, { env: env });
5774
if(obj.stdout.length > 0) {
5875
this.sourceURL = obj.stdout.toString().replace(/\n+$/, '');
59-
process.env.BINARY_DOWNLOAD_SOURCE_URL = this.sourceURL;
76+
this.downloadState.sourceURL = this.sourceURL;
6077
return this.sourceURL;
6178
} else if(obj.stderr.length > 0) {
6279
let output = Buffer.from(JSON.parse(JSON.stringify(obj.stderr)).data).toString();
@@ -70,23 +87,23 @@ function LocalBinary(){
7087
return callback(null, this.sourceURL);
7188
}
7289

73-
if (process.env.BINARY_DOWNLOAD_SOURCE_URL !== undefined && process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries != 4) {
90+
if (this.downloadState.sourceURL != null && this.downloadState.fallbackEnabled && this.parentRetries != 4) {
7491
/* This is triggered from Local.js if there's an error executing the downloaded binary */
75-
return callback(null, process.env.BINARY_DOWNLOAD_SOURCE_URL);
92+
return callback(null, this.downloadState.sourceURL);
7693
}
7794

7895
let downloadFallback = false;
7996
let downloadErrorMessage = null;
8097

81-
if (retries == 4 || (process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries == 4)) {
98+
if (retries == 4 || (this.downloadState.fallbackEnabled && this.parentRetries == 4)) {
8299
downloadFallback = true;
83-
downloadErrorMessage = this.downloadErrorMessage || process.env.BINARY_DOWNLOAD_ERROR_MESSAGE;
100+
downloadErrorMessage = this.downloadErrorMessage || this.downloadState.errorMessage;
84101
}
85102

86103
fetchDownloadSourceUrlAsync(this.key, this.bsHost, downloadFallback, downloadErrorMessage, conf.proxyHost, conf.proxyPort, conf.useCaCertificate, (err, sourceURL) => {
87104
if (err) return callback(err);
88105
this.sourceURL = sourceURL;
89-
process.env.BINARY_DOWNLOAD_SOURCE_URL = sourceURL;
106+
this.downloadState.sourceURL = sourceURL;
90107
callback(null, sourceURL);
91108
});
92109
};
@@ -135,10 +152,11 @@ function LocalBinary(){
135152
var that = this;
136153
if(retries > 0) {
137154
console.log('Retrying Download. Retries left', retries);
138-
fs.stat(binaryPath, function(err) {
139-
if(err == null) {
140-
fs.unlinkSync(binaryPath);
141-
}
155+
/* Single unlink instead of stat-then-unlinkSync: the gap between the two
156+
let a concurrent writer swap the file, and a failing unlinkSync threw
157+
out of the stat callback where it could not be caught. A missing file
158+
is the expected case here, so any error is ignored. */
159+
fs.unlink(binaryPath, function() {
142160
if(!callback) {
143161
return that.downloadSync(conf, destParentDir, retries - 1);
144162
}
@@ -310,18 +328,38 @@ function LocalBinary(){
310328
this.getAvailableDirs = function(){
311329
for(var i=0; i < this.orderedPaths.length; i++){
312330
var path = this.orderedPaths[i];
313-
if(this.makePath(path))
331+
// the last entry lives under the shared temp dir — it must be ours alone
332+
var requirePrivate = (i === this.orderedPaths.length - 1);
333+
if(this.makePath(path, requirePrivate))
314334
return path;
315335
}
316336
throw new LocalError('Error trying to download BrowserStack Local binary');
317337
};
318338

319-
this.makePath = function(path){
339+
this.makePath = function(path, requirePrivate){
320340
try {
321341
if(!this.checkPath(path)){
322-
fs.mkdirSync(path);
342+
fs.mkdirSync(path, { mode: 0o700 });
323343
}
324-
return true;
344+
return requirePrivate ? this.isUserPrivateDir(path) : true;
345+
} catch(e){
346+
return false;
347+
}
348+
};
349+
350+
/* Only applied to the shared-temp fallback. The binary is written there and
351+
then executed, so that directory must not be writable by anyone but us —
352+
otherwise another local user can swap the binary between the download and
353+
the exec, or pre-create the path as a symlink. Windows has no POSIX mode
354+
bits; there this is a no-op. */
355+
this.isUserPrivateDir = function(dirPath){
356+
if(process.platform === 'win32' || typeof process.getuid !== 'function') return true;
357+
try {
358+
var stats = fs.lstatSync(dirPath);
359+
if(!stats.isDirectory()) return false;
360+
if(stats.uid !== process.getuid()) return false;
361+
// reject group- or world-writable
362+
return (stats.mode & 0o022) === 0;
325363
} catch(e){
326364
return false;
327365
}
@@ -349,10 +387,18 @@ function LocalBinary(){
349387
return home || null;
350388
};
351389

390+
/* The last entry is a per-user subdirectory of the temp dir rather than the
391+
temp dir itself: os.tmpdir() is /tmp on Linux, which is world-writable, and
392+
the binary name below it is fixed and predictable. */
393+
this.tmpDirPath = function(){
394+
var suffix = (typeof process.getuid === 'function') ? String(process.getuid()) : 'user';
395+
return path.join(os.tmpdir(), 'browserstack-local-' + suffix);
396+
};
397+
352398
this.orderedPaths = [
353399
path.join(this.homedir(), '.browserstack'),
354400
process.cwd(),
355-
os.tmpdir()
401+
this.tmpDirPath()
356402
];
357403
}
358404

lib/download.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,33 @@ const https = require('https'),
22
fs = require('fs'),
33
HttpsProxyAgent = require('https-proxy-agent'),
44
url = require('url'),
5-
zlib = require('zlib');
5+
zlib = require('zlib'),
6+
{ isUndefined } = require('./util');
67

78
const binaryPath = process.argv[2], httpPath = process.argv[3], proxyHost = process.argv[4], proxyPort = process.argv[5], useCaCertificate = process.argv[6];
89

910
var fileStream = fs.createWriteStream(binaryPath);
1011

1112
var options = url.parse(httpPath);
12-
if(proxyHost && proxyPort) {
13+
/* isUndefined, not plain truthiness: the parent passes literal `undefined`
14+
placeholders for the proxy slots when only a CA is configured, and those
15+
arrive here as the *string* "undefined" — which is truthy, and previously
16+
built a proxy agent pointing at the host "undefined". */
17+
if(!isUndefined(proxyHost) && !isUndefined(proxyPort)) {
1318
options.agent = new HttpsProxyAgent({
1419
host: proxyHost,
1520
port: proxyPort
1621
});
17-
if (useCaCertificate) {
18-
try {
19-
options.ca = fs.readFileSync(useCaCertificate);
20-
} catch(err) {
21-
console.log('failed to read cert file', err);
22-
}
22+
}
23+
24+
/* Applied regardless of whether a proxy is configured: this is the caller's TLS
25+
trust anchor, and silently falling back to the system store when no proxy is
26+
set ignored what they asked for. Mirrors LocalBinary.js's async download path. */
27+
if (!isUndefined(useCaCertificate)) {
28+
try {
29+
options.ca = fs.readFileSync(useCaCertificate);
30+
} catch(err) {
31+
console.log('failed to read cert file', err);
2332
}
2433
}
2534

lib/fetchDownloadSourceUrl.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ const https = require('https'),
33
HttpsProxyAgent = require('https-proxy-agent'),
44
{ isUndefined } = require('./util');
55

6-
const authToken = process.argv[2], bsHost = process.argv[3], proxyHost = process.argv[6], proxyPort = process.argv[7], useCaCertificate = process.argv[8], downloadFallback = process.argv[4], downloadErrorMessage = process.argv[5];
6+
/* The auth token is read from the environment, never from argv: argv is world-readable
7+
via `ps` / /proc/<pid>/cmdline, whereas /proc/<pid>/environ is restricted to the
8+
owning user. Keep it out of this argument list. */
9+
const authToken = process.env.BROWSERSTACK_LOCAL_AUTH_TOKEN, bsHost = process.argv[2], proxyHost = process.argv[5], proxyPort = process.argv[6], useCaCertificate = process.argv[7], downloadFallback = process.argv[3], downloadErrorMessage = process.argv[4];
710

811
let body = '', data = {'auth_token': authToken};
912
const options = {

0 commit comments

Comments
 (0)