-
Notifications
You must be signed in to change notification settings - Fork 54
Scope binary-download fallback state per Local instance #178
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 |
|---|---|---|
|
|
@@ -20,24 +20,36 @@ function LocalBinary(){ | |
| this.baseRetries = 9; | ||
| this.sourceURL = null; | ||
| this.downloadErrorMessage = null; | ||
| /* | ||
| * Per-instance binary-download signalling. Historically these three fields were | ||
| * carried on process.env (BINARY_DOWNLOAD_FALLBACK_ENABLED / _ERROR_MESSAGE / | ||
| * _SOURCE_URL), which is a process-global mutable store: a failure on one Local | ||
| * instance bled into every other instance in the same process, and an attacker | ||
| * who could set the env before boot could force this instance to download from | ||
| * an arbitrary host. Keep the state on the instance instead. The owning Local | ||
| * object shares ONE downloadState object across the LocalBinary instances it | ||
| * recreates during a retry loop, so the fallback URL is still cached within a | ||
| * single Local instance without leaking across sibling instances. | ||
| */ | ||
|
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] After this change the constructor holds both: this.sourceURL = null; // :22
this.downloadErrorMessage = null; // :23
this.downloadState = { fallbackEnabled: false, errorMessage: null, sourceURL: null }; // :33The lifetimes genuinely differ — Worth a one-line comment at Non-blocking. |
||
| this.downloadState = { fallbackEnabled: false, errorMessage: null, sourceURL: null }; | ||
|
|
||
| this.getSourceUrlSync = function(conf, retries) { | ||
| /* Request for an endpoint to download the local binary from Rails no more than twice with 5 retries each */ | ||
| if (![4, 9].includes(retries) && this.sourceURL != null) { | ||
| return this.sourceURL; | ||
| } | ||
|
|
||
| if (process.env.BINARY_DOWNLOAD_SOURCE_URL !== undefined && process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries != 4) { | ||
| if (this.downloadState.sourceURL != null && this.downloadState.fallbackEnabled && this.parentRetries != 4) { | ||
| /* This is triggered from Local.js if there's an error executing the downloaded binary */ | ||
| return process.env.BINARY_DOWNLOAD_SOURCE_URL; | ||
| return this.downloadState.sourceURL; | ||
| } | ||
|
|
||
| let cmd, opts; | ||
| cmd = 'node'; | ||
| opts = [path.join(__dirname, 'fetchDownloadSourceUrl.js'), this.key, this.bsHost]; | ||
|
|
||
| if (retries == 4 || (process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries == 4)) { | ||
| opts.push(true, this.downloadErrorMessage || process.env.BINARY_DOWNLOAD_ERROR_MESSAGE); | ||
| if (retries == 4 || (this.downloadState.fallbackEnabled && this.parentRetries == 4)) { | ||
| opts.push(true, this.downloadErrorMessage || this.downloadState.errorMessage); | ||
| } else { | ||
| opts.push(false, null); | ||
| } | ||
|
|
@@ -56,7 +68,7 @@ function LocalBinary(){ | |
| const obj = childProcess.spawnSync(cmd, opts, { env: env }); | ||
| if(obj.stdout.length > 0) { | ||
| this.sourceURL = obj.stdout.toString().replace(/\n+$/, ''); | ||
| process.env.BINARY_DOWNLOAD_SOURCE_URL = this.sourceURL; | ||
| this.downloadState.sourceURL = this.sourceURL; | ||
| return this.sourceURL; | ||
| } else if(obj.stderr.length > 0) { | ||
| let output = Buffer.from(JSON.parse(JSON.stringify(obj.stderr)).data).toString(); | ||
|
|
@@ -70,23 +82,23 @@ function LocalBinary(){ | |
| return callback(null, this.sourceURL); | ||
| } | ||
|
|
||
| if (process.env.BINARY_DOWNLOAD_SOURCE_URL !== undefined && process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries != 4) { | ||
| if (this.downloadState.sourceURL != null && this.downloadState.fallbackEnabled && this.parentRetries != 4) { | ||
| /* This is triggered from Local.js if there's an error executing the downloaded binary */ | ||
| return callback(null, process.env.BINARY_DOWNLOAD_SOURCE_URL); | ||
| return callback(null, this.downloadState.sourceURL); | ||
| } | ||
|
|
||
| let downloadFallback = false; | ||
| let downloadErrorMessage = null; | ||
|
|
||
| if (retries == 4 || (process.env.BINARY_DOWNLOAD_FALLBACK_ENABLED == 'true' && this.parentRetries == 4)) { | ||
| if (retries == 4 || (this.downloadState.fallbackEnabled && this.parentRetries == 4)) { | ||
| downloadFallback = true; | ||
| downloadErrorMessage = this.downloadErrorMessage || process.env.BINARY_DOWNLOAD_ERROR_MESSAGE; | ||
| downloadErrorMessage = this.downloadErrorMessage || this.downloadState.errorMessage; | ||
| } | ||
|
|
||
| fetchDownloadSourceUrlAsync(this.key, this.bsHost, downloadFallback, downloadErrorMessage, conf.proxyHost, conf.proxyPort, conf.useCaCertificate, (err, sourceURL) => { | ||
| if (err) return callback(err); | ||
| this.sourceURL = sourceURL; | ||
| process.env.BINARY_DOWNLOAD_SOURCE_URL = sourceURL; | ||
| this.downloadState.sourceURL = sourceURL; | ||
| callback(null, sourceURL); | ||
| }); | ||
| }; | ||
|
|
||
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] This closes the chain by the ticket's alternative remediation, not its stated closure path — worth an explicit security sign-off rather than a silent merge.
LOC-6804's description opens with:
This PR instead implements the ticket's remediations #2 and #3 (remove the
BINARY_DOWNLOAD_SOURCE_URLenv-shortcut; replace theprocess.envsignalling with per-instance state). The ticket itself characterises that path as:I verified the env-mediated chain really is gone:
git grep 'process\.env' pr-178 -- lib/ index.jsleaves no environment variable that can influence the download source (onlyBROWSERSTACK_ACCESS_KEY,BROWSERSTACK_LOCAL_DEBUG_GZIP, andUSER_AGENT). So C-007 as written in the ticket — steps 1-3 all nameprocess.env— is genuinely closed.What remains, and why LOC-6777 is still the ticket's preferred breaker:
LocalBinary.binaryPath()reuses an already-executable binary out of the shared~/.browserstackdirectory (checkPath(binaryPath, fs.X_OK)), so one instance's downloaded bytes are still consumed by sibling instances with no integrity check. That channel is filesystem-mediated rather than env-mediated, and it is exactly what LOC-6777's pinned SHA-256 covers.The reasoning is disclosed openly in the Jira comment and the fix summary, so nothing here is misrepresented — it is a scope/policy decision, not a defect. What needs a human: does security accept C-007 as closed on the cheaper path, with LOC-6777 tracked separately for the residual download-to-RCE surface (C-001/C-003)? LOC-6777 needs the endpoint API to publish a pinned digest, which is server-side work owned by another team, so it cannot ship from this repo.
Not a merge blocker for this diff — flagging it so the closure decision is recorded rather than assumed.