Skip to content

Commit 0d54af8

Browse files
Merge pull request #180 from browserstack/locsec/WI-82ae3d6d
Keep the access key out of child argv; honour useCaCertificate without a proxy
2 parents 0975ede + 7852a15 commit 0d54af8

4 files changed

Lines changed: 69 additions & 20 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/LocalBinary.js‎

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ function LocalBinary(){
4646

4747
let cmd, opts;
4848
cmd = 'node';
49-
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];
5052

5153
if (retries == 4 || (this.downloadState.fallbackEnabled && this.parentRetries == 4)) {
5254
opts.push(true, this.downloadErrorMessage || this.downloadState.errorMessage);
@@ -65,6 +67,9 @@ function LocalBinary(){
6567

6668
const userAgent = [packageName, version].join('/');
6769
const env = Object.assign({ 'USER_AGENT': userAgent }, process.env);
70+
if (this.key) {
71+
env.BROWSERSTACK_LOCAL_AUTH_TOKEN = this.key;
72+
}
6873
const obj = childProcess.spawnSync(cmd, opts, { env: env });
6974
if(obj.stdout.length > 0) {
7075
this.sourceURL = obj.stdout.toString().replace(/\n+$/, '');
@@ -147,10 +152,11 @@ function LocalBinary(){
147152
var that = this;
148153
if(retries > 0) {
149154
console.log('Retrying Download. Retries left', retries);
150-
fs.stat(binaryPath, function(err) {
151-
if(err == null) {
152-
fs.unlinkSync(binaryPath);
153-
}
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() {
154160
if(!callback) {
155161
return that.downloadSync(conf, destParentDir, retries - 1);
156162
}
@@ -322,18 +328,38 @@ function LocalBinary(){
322328
this.getAvailableDirs = function(){
323329
for(var i=0; i < this.orderedPaths.length; i++){
324330
var path = this.orderedPaths[i];
325-
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))
326334
return path;
327335
}
328336
throw new LocalError('Error trying to download BrowserStack Local binary');
329337
};
330338

331-
this.makePath = function(path){
339+
this.makePath = function(path, requirePrivate){
332340
try {
333341
if(!this.checkPath(path)){
334-
fs.mkdirSync(path);
342+
fs.mkdirSync(path, { mode: 0o700 });
335343
}
336-
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;
337363
} catch(e){
338364
return false;
339365
}
@@ -361,10 +387,18 @@ function LocalBinary(){
361387
return home || null;
362388
};
363389

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+
364398
this.orderedPaths = [
365399
path.join(this.homedir(), '.browserstack'),
366400
process.cwd(),
367-
os.tmpdir()
401+
this.tmpDirPath()
368402
];
369403
}
370404

‎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)