-
Notifications
You must be signed in to change notification settings - Fork 54
LOC-6805: allowlist option keys forwarded to the BrowserStackLocal binary #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,68 @@ var childProcess = require('child_process'), | |
| version = require('../package.json').version, | ||
| treeKill = require('tree-kill'); | ||
|
|
||
| // Option keys this wrapper is allowed to forward verbatim to the | ||
| // BrowserStackLocal daemon. Mirrors the binary's own CLI definition | ||
| // (COMMAND_CONFIGURATION in browserStackTunnel, extensions/node/config/constants.js) | ||
| // — long names and their aliases — so every documented modifier keeps working | ||
| // while an unrecognised key can no longer reach the daemon argv. | ||
| // | ||
| // Deliberately a COMPLETE mirror, so it can be diffed against the binary's CLI | ||
| // when that gains a flag. Some entries (key, folder, force, only, forcelocal, | ||
| // verbose, onlyAutomate, proxyHost/Port/User/Pass, localIdentifier, forceproxy, | ||
| // logFile, parallelRuns) are handled by an explicit case in addArgs and so never | ||
| // reach this list at runtime; they are listed for completeness, not effect. | ||
| var PASSTHROUGH_OPTIONS = [ | ||
| 'key', 'folder', 'help', 'version', 'force', 'only', | ||
| 'forcelocal', 'force-local', | ||
| 'verbose', | ||
| 'onlyAutomate', 'only-automate', | ||
| 'proxyHost', 'proxy-host', | ||
| 'proxyPort', 'proxy-port', | ||
| 'proxyUser', 'proxy-user', | ||
| 'proxyPass', 'proxy-pass', | ||
| 'localIdentifier', 'local-identifier', | ||
| 'forceproxy', 'force-proxy', | ||
| 'region', | ||
| 'localProxyHost', 'local-proxy-host', | ||
| 'localProxyPort', 'local-proxy-port', | ||
| 'localProxyUser', 'local-proxy-user', | ||
| 'localProxyPass', 'local-proxy-pass', | ||
| 'enableLoggingForAPI', 'enable-logging-for-api', | ||
| 'logFile', | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] 15 of the 55 entries here are unreachable — they're shadowed by an explicit
Harmless at runtime, but the list is the thing a maintainer will diff against Verified separately: against
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Took the second option you offered — kept the entries, added the note — since a complete mirror is what makes the list diffable against
The Thanks for the independent check that the allowlist has no missing binary option and no extras — that's the part I'd have had the hardest time proving to a reviewer myself. |
||
| 'pacFile', 'pac-file', | ||
| 'parallelRuns', 'parallel-runs', | ||
| 'disableProxyDiscovery', 'disable-proxy-discovery', | ||
| 'enableUTCLogging', 'enable-utc-logging', | ||
| 'no-container', | ||
| 'include-hosts', 'exclude-hosts', | ||
| 'bsHost', 'bs-host', | ||
| 'debug-utility', 'debug-url', | ||
| 'customRepeater', 'custom-repeater', | ||
| 'enterprise', | ||
| 'use-system-installed-ca', 'use-ca-certificate', | ||
| 'https-ports', | ||
| 'ntlm-username', 'ntlm-password', 'ntlm-domain', 'ntlm-workstation', | ||
| 'connect-timeout', | ||
| 'public-interface-services', | ||
| 'disableDashboard', 'disable-dashboard', | ||
| 'config-file', | ||
| 'client-protocol', | ||
| 'identifier', | ||
| 'trusted-hosts' | ||
| ]; | ||
|
|
||
| // Flags getBinaryArgs() always puts on the argv itself. Accepting them from | ||
| // the options object too would let a caller append a second, conflicting copy | ||
| // — e.g. '--daemon stop' after our '--daemon start'. The log file is settable, | ||
| // but only through the wrapper's own 'logfile'/'logFile' case above, which | ||
| // routes it into getBinaryArgs' single '--log-file'; the binary's raw | ||
| // 'log-file' alias is reserved so it cannot add a second one. | ||
| var RESERVED_OPTIONS = ['daemon', 'log-file', 'source']; | ||
|
|
||
| // Keys consumed by this wrapper and never meant for the binary. | ||
| var INTERNAL_OPTIONS = ['onlyCommand']; | ||
|
|
||
| function Local(){ | ||
| this.sanitizePath = function(rawPath) { | ||
| var doubleQuoteIfRequired = this.windows && !rawPath.match(/"[^"]+"/) ? '"' : ''; | ||
|
|
@@ -30,7 +92,9 @@ function Local(){ | |
| this.startSync = function(options) { | ||
| this.userArgs = []; | ||
| var that = this; | ||
| this.addArgs(options); | ||
| const argsError = this.addArgs(options); | ||
| if(argsError) | ||
| return argsError; | ||
|
|
||
| if(typeof options['onlyCommand'] !== 'undefined') | ||
| return; | ||
|
|
@@ -83,7 +147,9 @@ function Local(){ | |
| this.start = function(options, callback){ | ||
| this.userArgs = []; | ||
| var that = this; | ||
| this.addArgs(options); | ||
| const argsError = this.addArgs(options); | ||
| if(argsError) | ||
| return callback(argsError); | ||
|
|
||
| if(typeof options['onlyCommand'] !== 'undefined') | ||
| return callback(); | ||
|
|
@@ -150,6 +216,15 @@ function Local(){ | |
| for(var key in options){ | ||
| var value = options[key]; | ||
|
|
||
| // Runs for EVERY key, including the ones with an explicit case below. | ||
| // A value is only ever safe as a value: the binary's parser will not | ||
| // consume one that begins with '-', it reads it as another flag — and it | ||
| // accepts the '--flag=value' form, so a value like '--log-file=/tmp/x' | ||
| // smuggles a complete flag in through an otherwise legitimate option. | ||
| var valueError = this.rejectFlagLikeValue(key, value); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Round-1 confirmation — this is fixed, verified rather than assumed. I re-ran every round-0 vector plus new ones against head All 18 explicitly-cased keys now reject a flag-like value — Equally important, the accept path didn't get over-tightened: Hoisting above the |
||
| if(valueError) | ||
| return valueError; | ||
|
|
||
| switch(key){ | ||
| case 'key': | ||
| if(value) | ||
|
|
@@ -245,15 +320,61 @@ function Local(){ | |
| this.binaryPath = value; | ||
| break; | ||
|
|
||
| default: | ||
| if(value.toString().toLowerCase() == 'true'){ | ||
| this.userArgs.push('--' + key); | ||
| } else { | ||
| this.userArgs.push('--' + key); | ||
| this.userArgs.push(value); | ||
| } | ||
| default: { | ||
| var error = this.addUserArg(key, value); | ||
| if(error) | ||
| return error; | ||
| break; | ||
| } | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Returns a LocalError if any part of the value would be read as a flag | ||
| // rather than as this option's value. List-typed options (--include-hosts, | ||
| // --exclude-hosts) take an array, so every element is checked. | ||
| this.rejectFlagLikeValue = function(key, value){ | ||
| var values = Array.isArray(value) ? value : [value]; | ||
| for(var i = 0; i < values.length; i++){ | ||
| if(values[i] === undefined || values[i] === null) | ||
| continue; | ||
| if(values[i].toString().charAt(0) === '-') | ||
| return new LocalError('Invalid value for option \'' + key + '\': values starting with \'-\' are not allowed'); | ||
| } | ||
| }; | ||
|
|
||
| // Forwards one caller-supplied option to the daemon argv, or returns a | ||
| // LocalError describing why it was refused. Only documented modifiers get | ||
| // through: an unknown key used to be prefixed with '--' and pushed blindly, | ||
| // which let any caller inject arbitrary flags into the native binary. | ||
| // The value has already been checked by rejectFlagLikeValue in addArgs. | ||
| this.addUserArg = function(key, value){ | ||
| if(INTERNAL_OPTIONS.indexOf(key) !== -1) | ||
| return; | ||
|
|
||
| if(RESERVED_OPTIONS.indexOf(key) !== -1) | ||
| return new LocalError('Option \'' + key + '\' is set by browserstack-local itself and cannot be passed in'); | ||
|
|
||
| if(PASSTHROUGH_OPTIONS.indexOf(key) === -1) | ||
| return new LocalError('Unknown option \'' + key + '\'. Only documented BrowserStack Local modifiers are forwarded to the binary, see https://www.browserstack.com/local-testing#modifiers'); | ||
|
|
||
| // Match the explicit cases above, which all guard with `if(value)`. | ||
| if(value === undefined || value === null) | ||
| return; | ||
|
|
||
| if(value.toString().toLowerCase() == 'true'){ | ||
| this.userArgs.push('--' + key); | ||
| return; | ||
| } | ||
|
|
||
| // argv elements must be strings — execFile/spawnSync reject anything else. | ||
| this.userArgs.push('--' + key); | ||
| if(Array.isArray(value)){ | ||
| for(var i = 0; i < value.length; i++){ | ||
| this.userArgs.push(value[i].toString()); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The scalar path above got the null/undefined guard I asked for in round 0, but this new array loop didn't — and Thrown synchronously out of if(Array.isArray(value)){
for(var i = 0; i < value.length; i++){
if(value[i] === undefined || value[i] === null)
continue;
this.userArgs.push(value[i].toString());
}
}Two cosmetic siblings while you're here: |
||
| } | ||
| } else { | ||
| this.userArgs.push(value.toString()); | ||
| } | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -124,20 +124,136 @@ describe('Local', function () { | |
| }); | ||
| }); | ||
|
|
||
| it('should enable custom boolean args', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'boolArg1': true, 'boolArg2': true }, function(){ | ||
| expect(bsLocal.getBinaryArgs().indexOf('--boolArg1')).to.not.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--boolArg2')).to.not.equal(-1); | ||
| // Argument injection (CWE-88): addArgs used to prefix ANY unknown option key | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocking] The tree is clean now — The squash-merge note doesn't finish the job, though, for two reasons:
The substantive part isn't the bare ticket key — it's that This branch is a Draft with no other contributors, so amending is free: git rebase -i --root # or: git commit --amend on 1964cb1 via a soft reset
git push --force-with-leaseRewrite |
||
| // with '--' and push it onto the daemon argv, letting a caller — or upstream | ||
| // code merging untrusted input into `options` — inject arbitrary flags into | ||
| // the native binary. Only documented BrowserStackLocal modifiers may be | ||
| // forwarded now, and no value may pose as a flag. | ||
|
|
||
| it('should reject unknown boolean args', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'boolArg1': true, 'boolArg2': true }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(error.toString()).to.contain('Unknown option \'boolArg1\''); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--boolArg1')).to.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--boolArg2')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should enable custom keyval args', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'customKey1': 'custom value1', 'customKey2': 'custom value2' }, function(){ | ||
| expect(bsLocal.getBinaryArgs().indexOf('--customKey1')).to.not.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('custom value1')).to.not.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--customKey2')).to.not.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('custom value2')).to.not.equal(-1); | ||
| it('should reject unknown keyval args', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'customKey1': 'custom value1', 'customKey2': 'custom value2' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(error.toString()).to.contain('Unknown option \'customKey1\''); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--customKey1')).to.equal(-1); | ||
| expect(bsLocal.getBinaryArgs().indexOf('custom value1')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject the reported flag-injection payload', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'config': '/tmp/attacker.conf', 'daemon': 'stop' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| const args = bsLocal.getBinaryArgs(); | ||
| expect(args.indexOf('--config')).to.equal(-1); | ||
| expect(args.indexOf('/tmp/attacker.conf')).to.equal(-1); | ||
| // the wrapper's own '--daemon start' must be the only daemon flag | ||
| expect(args.indexOf('stop')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject options the wrapper sets itself', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'log-file': '/tmp/attacker-owned' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(error.toString()).to.contain('set by browserstack-local itself'); | ||
| expect(bsLocal.getBinaryArgs().indexOf('/tmp/attacker-owned')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject a value that would be parsed as another flag', function (done) { | ||
| // bs-minimist does not consume a value beginning with '-'; it reads it as | ||
| // a separate flag, so a legitimate key can still smuggle one in. | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'region': '--pac-file' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(error.toString()).to.contain('values starting with \'-\' are not allowed'); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--pac-file')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| // The value check must cover keys that have an explicit case too, not just | ||
| // the ones reaching the allowlist. bs-minimist accepts '--flag=value', so a | ||
| // smuggled flag carries its own value and needs no following argv slot. | ||
| it('should reject a flag-like value on an explicitly handled option', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'localIdentifier': '--log-file=/tmp/attacker-owned' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(error.toString()).to.contain('values starting with \'-\' are not allowed'); | ||
| const args = bsLocal.getBinaryArgs(); | ||
| expect(args.indexOf('--log-file=/tmp/attacker-owned')).to.equal(-1); | ||
| expect(args.indexOf('--local-identifier')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject a daemon-lifecycle smuggle through an explicitly handled option', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'only': '--daemon=stop' }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--daemon=stop')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should reject a flag-like element inside a list-valued option', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'include-hosts': ['localhost', '--config-file=/tmp/attacker.yml'] }, function(error){ | ||
| expect(error).to.be.an(Error); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--config-file=/tmp/attacker.yml')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should forward a list-valued option as separate argv elements', function (done) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Good addition — this test is what makes the new array handling a contract rather than an accident, and pairing it with the flag-like-element case above is the right split. Two gaps worth closing while the file is open, both of which I had to find by hand rather than from the suite:
Separately, on the PR description rather than this file: the test plan dropped the BrowserStack session id that round 0's body carried. The round-1 re-run is real — I verified session |
||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'include-hosts': ['localhost', '127.0.0.1'] }, function(error){ | ||
| expect(error).to.equal(undefined); | ||
| const args = bsLocal.getBinaryArgs(); | ||
| expect(args.indexOf('--include-hosts')).to.not.equal(-1); | ||
| expect(args.indexOf('localhost')).to.not.equal(-1); | ||
| expect(args.indexOf('127.0.0.1')).to.not.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should skip a null or undefined passthrough value instead of pushing it raw', function (done) { | ||
| // execFile/spawnSync reject a non-string argv element, so a raw null here | ||
| // used to throw ERR_INVALID_ARG_TYPE out of start() instead of erroring. | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'region': null, 'connect-timeout': 30 }, function(error){ | ||
| expect(error).to.equal(undefined); | ||
| const args = bsLocal.getBinaryArgs(); | ||
| expect(args.indexOf('--region')).to.equal(-1); | ||
| expect(args.indexOf(null)).to.equal(-1); | ||
| // numbers are coerced, so every argv element is a string | ||
| expect(args.indexOf('30')).to.not.equal(-1); | ||
| expect(args.every(function(a){ return typeof a === 'string'; })).to.equal(true); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should not forward wrapper-internal keys to the binary', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true }, function(error){ | ||
| expect(error).to.equal(undefined); | ||
| expect(bsLocal.getBinaryArgs().indexOf('--onlyCommand')).to.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
| it('should still forward documented modifiers that have no explicit case', function (done) { | ||
| bsLocal.start({ 'key': process.env.BROWSERSTACK_ACCESS_KEY, onlyCommand: true, 'localProxyHost': '127.0.0.1', 'pac-file': '/tmp/proxy.pac' }, function(error){ | ||
| expect(error).to.equal(undefined); | ||
| const args = bsLocal.getBinaryArgs(); | ||
| expect(args.indexOf('--localProxyHost')).to.not.equal(-1); | ||
| expect(args.indexOf('127.0.0.1')).to.not.equal(-1); | ||
| expect(args.indexOf('--pac-file')).to.not.equal(-1); | ||
| expect(args.indexOf('/tmp/proxy.pac')).to.not.equal(-1); | ||
| done(); | ||
| }); | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[for-human] Two scope calls the PR flags but can't settle on its own — both need someone to decide, not more code here:
Sibling bindings.
browserstack-local-python/-ruby/-java/-php/-csharpcarry the sameaddArgscatch-all shape. If F-007 applies there it needs its own tickets — none exist, and none of those repos are in scope for this work item. Worth confirming and filing before this closes, otherwise the same primitive stays open in five packages while the chain ticket reads as resolved.index.d.tsstill declares[key: string]: string | boolean. TypeScript users get no compile-time signal for a key that now throws at runtime. Removing the index signature would break the documented modifiers the interface doesn't enumerate (pac-file,localProxyHost,region, …), so the real fix is to enumerate them — a typings pass, reasonably a follow-up rather than this PR.Also for the record, since it changes what a caller sees: an unknown key is now a hard error rather than a silent no-op forward. LOC-6786 offered "silently ignore and warn" as an alternative and this PR deliberately didn't take it. I agree with that choice — a silent drop hides caller bugs — but it's a breaking change for anyone currently passing a typo'd or stale key, so it wants a minor-version bump and a changelog line, not a patch release.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Acknowledged — both are genuinely human calls and I'm not deciding either here. They're carried in the Jira ticket's assessment under "Not tested" / follow-ups (sibling bindings not in this repo or work item;
index.d.tsindex signature left as-is because removing it would break the documented modifiers the interface doesn't enumerate). No code change this round.