Skip to content

Commit 4ab6262

Browse files
07souravkundaclaude
andcommitted
Apply the value check to every option key, not just passthrough ones
Review round 1. The flag-like-value guard lived in addUserArg(), which only runs for keys reaching the default: branch, so all 21 options with an explicit switch case bypassed it. Because the binary's parser accepts the '--flag=value' form, the smuggled flag carries its own value and needs no following argv slot: {localIdentifier: '--log-file=/tmp/attacker-owned'} -> [..., '--local-identifier', '--log-file=/tmp/attacker-owned'] which the parser reads as a second --log-file. Reachable the same way through only, folder, proxyHost/Port/User/Pass, parallelRuns, useCaCertificate, logFile, key and verbose — i.e. through exactly the keys the README documents. That also defeated RESERVED_OPTIONS, since --daemon= and --log-file= could ride in as values. Hoist the check into addArgs, above the switch, so it applies to every key. List-valued options (--include-hosts, --exclude-hosts) take an array, so every element is checked, not just the first. Also from review: - skip a null/undefined passthrough value, matching the `if(value)` guard every explicit case already uses, and push a coerced string so no non-string argv element reaches execFile/spawnSync; array values are pushed as separate elements, which is what a list flag expects - note in PASSTHROUGH_OPTIONS that the entries shadowed by an explicit case are listed for completeness against the binary's CLI, and make the log-file/logFile split explicit rather than contradictory - drop the internal tracker ids from the test comment; this repo is public and no such id currently ships in the tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1964cb1 commit 4ab6262

2 files changed

Lines changed: 106 additions & 15 deletions

File tree

lib/Local.js

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ var childProcess = require('child_process'),
1414
// (COMMAND_CONFIGURATION in browserStackTunnel, extensions/node/config/constants.js)
1515
// — long names and their aliases — so every documented modifier keeps working
1616
// while an unrecognised key can no longer reach the daemon argv.
17+
//
18+
// Deliberately a COMPLETE mirror, so it can be diffed against the binary's CLI
19+
// when that gains a flag. Some entries (key, folder, force, only, forcelocal,
20+
// verbose, onlyAutomate, proxyHost/Port/User/Pass, localIdentifier, forceproxy,
21+
// logFile, parallelRuns) are handled by an explicit case in addArgs and so never
22+
// reach this list at runtime; they are listed for completeness, not effect.
1723
var PASSTHROUGH_OPTIONS = [
1824
'key', 'folder', 'help', 'version', 'force', 'only',
1925
'forcelocal', 'force-local',
@@ -56,8 +62,10 @@ var PASSTHROUGH_OPTIONS = [
5662

5763
// Flags getBinaryArgs() always puts on the argv itself. Accepting them from
5864
// the options object too would let a caller append a second, conflicting copy
59-
// — e.g. '--daemon stop' after our '--daemon start'. ('logFile' has its own
60-
// supported option; only the raw binary alias is reserved.)
65+
// — e.g. '--daemon stop' after our '--daemon start'. The log file is settable,
66+
// but only through the wrapper's own 'logfile'/'logFile' case above, which
67+
// routes it into getBinaryArgs' single '--log-file'; the binary's raw
68+
// 'log-file' alias is reserved so it cannot add a second one.
6169
var RESERVED_OPTIONS = ['daemon', 'log-file', 'source'];
6270

6371
// Keys consumed by this wrapper and never meant for the binary.
@@ -208,6 +216,15 @@ function Local(){
208216
for(var key in options){
209217
var value = options[key];
210218

219+
// Runs for EVERY key, including the ones with an explicit case below.
220+
// A value is only ever safe as a value: the binary's parser will not
221+
// consume one that begins with '-', it reads it as another flag — and it
222+
// accepts the '--flag=value' form, so a value like '--log-file=/tmp/x'
223+
// smuggles a complete flag in through an otherwise legitimate option.
224+
var valueError = this.rejectFlagLikeValue(key, value);
225+
if(valueError)
226+
return valueError;
227+
211228
switch(key){
212229
case 'key':
213230
if(value)
@@ -313,10 +330,24 @@ function Local(){
313330
}
314331
};
315332

333+
// Returns a LocalError if any part of the value would be read as a flag
334+
// rather than as this option's value. List-typed options (--include-hosts,
335+
// --exclude-hosts) take an array, so every element is checked.
336+
this.rejectFlagLikeValue = function(key, value){
337+
var values = Array.isArray(value) ? value : [value];
338+
for(var i = 0; i < values.length; i++){
339+
if(values[i] === undefined || values[i] === null)
340+
continue;
341+
if(values[i].toString().charAt(0) === '-')
342+
return new LocalError('Invalid value for option \'' + key + '\': values starting with \'-\' are not allowed');
343+
}
344+
};
345+
316346
// Forwards one caller-supplied option to the daemon argv, or returns a
317347
// LocalError describing why it was refused. Only documented modifiers get
318348
// through: an unknown key used to be prefixed with '--' and pushed blindly,
319349
// which let any caller inject arbitrary flags into the native binary.
350+
// The value has already been checked by rejectFlagLikeValue in addArgs.
320351
this.addUserArg = function(key, value){
321352
if(INTERNAL_OPTIONS.indexOf(key) !== -1)
322353
return;
@@ -327,20 +358,24 @@ function Local(){
327358
if(PASSTHROUGH_OPTIONS.indexOf(key) === -1)
328359
return new LocalError('Unknown option \'' + key + '\'. Only documented BrowserStack Local modifiers are forwarded to the binary, see https://www.browserstack.com/local-testing#modifiers');
329360

330-
var stringValue = value === undefined || value === null ? '' : value.toString();
361+
// Match the explicit cases above, which all guard with `if(value)`.
362+
if(value === undefined || value === null)
363+
return;
331364

332-
if(stringValue.toLowerCase() == 'true'){
365+
if(value.toString().toLowerCase() == 'true'){
333366
this.userArgs.push('--' + key);
334367
return;
335368
}
336369

337-
// The binary's argv parser will not consume a value that begins with '-';
338-
// it reads it as another flag instead. Refuse rather than smuggle one in.
339-
if(stringValue.charAt(0) === '-')
340-
return new LocalError('Invalid value for option \'' + key + '\': values starting with \'-\' are not allowed');
341-
370+
// argv elements must be strings — execFile/spawnSync reject anything else.
342371
this.userArgs.push('--' + key);
343-
this.userArgs.push(value);
372+
if(Array.isArray(value)){
373+
for(var i = 0; i < value.length; i++){
374+
this.userArgs.push(value[i].toString());
375+
}
376+
} else {
377+
this.userArgs.push(value.toString());
378+
}
344379
};
345380

346381
this.getBinaryPath = function(callback, bsHost){

test/local.js

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,11 @@ describe('Local', function () {
124124
});
125125
});
126126

127-
// LOC-6805 / LOC-6783 (F-007, CWE-88): addArgs used to prefix ANY unknown
128-
// option key with '--' and push it onto the daemon argv, letting a caller
129-
// — or upstream code merging untrusted input into `options` — inject
130-
// arbitrary flags into the native binary. Only documented BrowserStackLocal
131-
// modifiers may be forwarded now.
127+
// Argument injection (CWE-88): addArgs used to prefix ANY unknown option key
128+
// with '--' and push it onto the daemon argv, letting a caller — or upstream
129+
// code merging untrusted input into `options` — inject arbitrary flags into
130+
// the native binary. Only documented BrowserStackLocal modifiers may be
131+
// forwarded now, and no value may pose as a flag.
132132

133133
it('should reject unknown boolean args', function (done) {
134134
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'boolArg1': true, 'boolArg2': true }, function(error){
@@ -182,6 +182,62 @@ describe('Local', function () {
182182
});
183183
});
184184

185+
// The value check must cover keys that have an explicit case too, not just
186+
// the ones reaching the allowlist. bs-minimist accepts '--flag=value', so a
187+
// smuggled flag carries its own value and needs no following argv slot.
188+
it('should reject a flag-like value on an explicitly handled option', function (done) {
189+
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'localIdentifier': '--log-file=/tmp/attacker-owned' }, function(error){
190+
expect(error).to.be.an(Error);
191+
expect(error.toString()).to.contain('values starting with \'-\' are not allowed');
192+
const args = bsLocal.getBinaryArgs();
193+
expect(args.indexOf('--log-file=/tmp/attacker-owned')).to.equal(-1);
194+
expect(args.indexOf('--local-identifier')).to.equal(-1);
195+
done();
196+
});
197+
});
198+
199+
it('should reject a daemon-lifecycle smuggle through an explicitly handled option', function (done) {
200+
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'only': '--daemon=stop' }, function(error){
201+
expect(error).to.be.an(Error);
202+
expect(bsLocal.getBinaryArgs().indexOf('--daemon=stop')).to.equal(-1);
203+
done();
204+
});
205+
});
206+
207+
it('should reject a flag-like element inside a list-valued option', function (done) {
208+
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'include-hosts': ['localhost', '--config-file=/tmp/attacker.yml'] }, function(error){
209+
expect(error).to.be.an(Error);
210+
expect(bsLocal.getBinaryArgs().indexOf('--config-file=/tmp/attacker.yml')).to.equal(-1);
211+
done();
212+
});
213+
});
214+
215+
it('should forward a list-valued option as separate argv elements', function (done) {
216+
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'include-hosts': ['localhost', '127.0.0.1'] }, function(error){
217+
expect(error).to.equal(undefined);
218+
const args = bsLocal.getBinaryArgs();
219+
expect(args.indexOf('--include-hosts')).to.not.equal(-1);
220+
expect(args.indexOf('localhost')).to.not.equal(-1);
221+
expect(args.indexOf('127.0.0.1')).to.not.equal(-1);
222+
done();
223+
});
224+
});
225+
226+
it('should skip a null or undefined passthrough value instead of pushing it raw', function (done) {
227+
// execFile/spawnSync reject a non-string argv element, so a raw null here
228+
// used to throw ERR_INVALID_ARG_TYPE out of start() instead of erroring.
229+
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'region': null, 'connect-timeout': 30 }, function(error){
230+
expect(error).to.equal(undefined);
231+
const args = bsLocal.getBinaryArgs();
232+
expect(args.indexOf('--region')).to.equal(-1);
233+
expect(args.indexOf(null)).to.equal(-1);
234+
// numbers are coerced, so every argv element is a string
235+
expect(args.indexOf('30')).to.not.equal(-1);
236+
expect(args.every(function(a){ return typeof a === 'string'; })).to.equal(true);
237+
done();
238+
});
239+
});
240+
185241
it('should not forward wrapper-internal keys to the binary', function (done) {
186242
bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true }, function(error){
187243
expect(error).to.equal(undefined);

0 commit comments

Comments
 (0)