@@ -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
0 commit comments