Skip to content

Commit 5341a73

Browse files
Merge pull request #48 from browserstack/release_1.5.1
Release 1.5.1
2 parents 6a67875 + 123c882 commit 5341a73

9 files changed

Lines changed: 218 additions & 20 deletions

File tree

‎.github/workflows/gem-push.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ jobs:
1111
id-token: write
1212

1313
steps:
14-
- uses: actions/checkout@v3
14+
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
1515
- name: Set up Ruby 2.6
16-
uses: ruby/setup-ruby@v1
16+
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
1717
with:
1818
ruby-version: 2.6.10
1919

20-
- uses: rubygems/configure-rubygems-credentials@v2.0.0
20+
- uses: rubygems/configure-rubygems-credentials@762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.0
2121
- name: Build and push gem
2222
run: |
2323
gem build *.gemspec

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
dist/*
22
*.log
33
browserstack.err
4+
5+
# Local Bundler state. .bundle/config can carry settings that weaken install-time
6+
# integrity checks (e.g. disable_checksum_validation), so it must never be committed.
7+
.bundle/
8+
vendor/bundle/

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased] - yyyy-mm-dd
88

9+
## [1.5.1] - 2026-09-25
10+
11+
### Improvements
12+
13+
- Prevent shell commands passed through logfile path.
14+
915
## [1.5.0] - 2026-06-01
1016

1117
### Added

‎Gemfile‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
source "http://rubygems.org"
1+
source "https://rubygems.org"
22
gem "minitest"
33
gem "rake"
4-
gem "json"
4+
# "json" is intentionally NOT listed: lib/ uses the `json` default gem that ships
5+
# with Ruby, and the gemspec declares no dependency on it, so a third-party json
6+
# build is a redundant build-time dependency (and a native extension) to pull in.

‎Gemfile.lock‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,28 @@
11
GEM
2-
remote: http://rubygems.org/
2+
remote: https://rubygems.org/
33
specs:
4-
json (1.8.3)
5-
minitest (5.8.4)
6-
rake (12.3.3)
4+
drb (2.2.3)
5+
minitest (6.0.6)
6+
drb (~> 2.0)
7+
prism (~> 1.5)
8+
prism (1.9.0)
9+
rake (13.4.2)
710

811
PLATFORMS
12+
aarch64-linux
13+
arm64-darwin-24
914
ruby
15+
x86_64-linux
1016

1117
DEPENDENCIES
12-
json
1318
minitest
1419
rake
1520

21+
CHECKSUMS
22+
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
23+
minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
24+
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
25+
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
26+
1627
BUNDLED WITH
17-
1.11.2
28+
2.7.1

‎lib/browserstack/local.rb‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
require 'browserstack/localbinary'
22
require 'browserstack/localexception'
33
require 'json'
4+
require 'fileutils'
45

56
module BrowserStack
67

@@ -73,10 +74,16 @@ def start(options = {})
7374
@binary_path
7475
end
7576

76-
if @is_windows
77-
system("echo > #{@logfile}")
78-
else
79-
system("echo '' > '#{@logfile}'")
77+
# Create/truncate the logfile without a shell. The previous
78+
# `system("echo ... > #{@logfile}")` passed @logfile to /bin/sh (or cmd.exe),
79+
# so shell metacharacters in a caller-supplied logfile path executed as commands
80+
# (CWE-78). File.write treats the path purely as a filename.
81+
logfile_dir = File.dirname(@logfile)
82+
FileUtils.mkdir_p(logfile_dir) unless File.directory?(logfile_dir)
83+
begin
84+
File.write(@logfile, "")
85+
rescue SystemCallError => e
86+
raise BrowserStack::LocalException.new("Unable to open logfile: #{e.message}")
8087
end
8188

8289
if defined? spawn
@@ -121,12 +128,27 @@ def stop
121128
@pid = nil
122129
end
123130

131+
# Public accessor used by callers for debugging/logging. Return the command
132+
# with the access key masked so it is never written to logs, CI artifacts or
133+
# error trackers (CWE-312). The real key is still used for execution via
134+
# start_command_args / start_command(false).
124135
def command
125-
start_command
136+
start_command(true)
137+
end
138+
139+
# Prevent Ruby's default #inspect from dumping @key when a Local instance is
140+
# logged or included in an exception payload (CWE-312).
141+
def inspect
142+
redacted = instance_variables.map do |var|
143+
value = var == :@key && !@key.to_s.empty? ? "[REDACTED]" : instance_variable_get(var)
144+
"#{var}=#{value.inspect}"
145+
end.join(", ")
146+
"#<#{self.class}:0x#{format('%016x', object_id << 1)} #{redacted}>"
126147
end
127148

128-
def start_command
129-
cmd = "#{@binary_path} -d start -logFile '#{@logfile}' #{@folder_flag} #{@key} #{@folder_path} #{@force_local_flag}"
149+
def start_command(redact = false)
150+
key = redact && !@key.to_s.empty? ? "[REDACTED]" : @key
151+
cmd = "#{@binary_path} -d start -logFile '#{@logfile}' #{@folder_flag} #{key} #{@folder_path} #{@force_local_flag}"
130152
cmd += " -localIdentifier #{@local_identifier_flag}" if @local_identifier_flag
131153
cmd += " #{@only_flag} #{@only_automate_flag}"
132154
cmd += " -proxyHost #{@proxy_host}" if @proxy_host

‎lib/browserstack/localbinary.rb‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,14 @@ def download_to(url, bin_path)
133133
end
134134

135135
def verify_binary(bin_path)
136-
binary_response = IO.popen(bin_path + " --version").readline
136+
# Array form: exec's the binary directly, so a path containing shell
137+
# metacharacters or spaces is never interpreted by /bin/sh (CWE-78).
138+
#
139+
# The scanner rule below fires on any non-static first argument to IO.popen
140+
# and does not model the array form -- which is exactly the fix here, since
141+
# no shell is spawned at all. Suppressed for this rule only.
142+
# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec
143+
binary_response = IO.popen([bin_path, '--version']).readline
137144
!!(binary_response =~ /BrowserStack Local version \d+\.\d+/)
138145
rescue StandardError
139146
false

‎lib/browserstack/version.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module BrowserStack
2-
VERSION = '1.5.0'.freeze
2+
VERSION = '1.5.1'.freeze
33
end

‎test/browserstack-local-test.rb‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,37 @@
11
require 'rubygems'
22
require 'minitest'
33
require 'minitest/autorun'
4+
require 'minitest/mock'
5+
require 'tmpdir'
46
require 'browserstack/local'
57

68
class BrowserStackLocalTest < Minitest::Test
79
def setup
810
@bs_local = BrowserStack::Local.new
911
end
1012

13+
# The tests below actually start the BrowserStackLocal binary and open a
14+
# tunnel, so they need a valid BROWSERSTACK_ACCESS_KEY and network access.
15+
# Skip them (instead of erroring) when no key is available so the rest of
16+
# the suite stays green in credential-less environments such as CI.
17+
def skip_without_credentials
18+
skip 'requires BROWSERSTACK_ACCESS_KEY (live integration test)' if ENV['BROWSERSTACK_ACCESS_KEY'].to_s.empty?
19+
end
20+
1121
def test_check_pid
22+
skip_without_credentials
1223
@bs_local.start
1324
refute_nil @bs_local.pid, 0
1425
end
1526

1627
def test_is_running
28+
skip_without_credentials
1729
@bs_local.start
1830
assert_equal true, @bs_local.isRunning
1931
end
2032

2133
def test_multiple_binary
34+
skip_without_credentials
2235
@bs_local.start
2336
bs_local_2 = BrowserStack::Local.new
2437
second_log_file = File.join(Dir.pwd, 'local2.log')
@@ -96,11 +109,87 @@ def test_hosts
96109
assert_match /localhost\,8080\,0/, @bs_local.command
97110
end
98111

112+
# Regression for CWE-312: the public #command accessor must NOT expose the
113+
# access key — callers routinely log it to CI output / APM / error trackers.
114+
def test_command_redacts_access_key
115+
bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY")
116+
refute_match /MY_SECRET_ACCESS_KEY/, bs.command
117+
assert_match /\[REDACTED\]/, bs.command
118+
end
119+
120+
# The real key must still reach the binary on the execution path.
121+
def test_start_command_keeps_key_for_execution
122+
bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY")
123+
assert_match /MY_SECRET_ACCESS_KEY/, bs.start_command
124+
end
125+
126+
# Regression for CWE-312: default object inspection must not dump the key.
127+
def test_inspect_redacts_access_key
128+
bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY")
129+
refute_match /MY_SECRET_ACCESS_KEY/, bs.inspect
130+
assert_match /\[REDACTED\]/, bs.inspect
131+
end
132+
99133
def teardown
100134
@bs_local.stop
101135
end
102136
end
103137

138+
# Regression tests for the logfile-creation step in Local#start (CWE-78).
139+
# The logfile used to be created with `system("echo ... > #{@logfile}")`, which
140+
# passed the caller-supplied path through a shell. These tests drive the public
141+
# `start` entry point but abort just after the logfile step (a fake binarypath
142+
# skips the download; stubbing start_command_args prevents launching the binary),
143+
# so they need no credentials, network, or tunnel.
144+
class BrowserStackLocalLogfileTest < Minitest::Test
145+
class AbortAfterLogfile < StandardError; end
146+
147+
# Runs `start` with the given logfile value, aborting right after the logfile
148+
# is created (before the real binary is spawned).
149+
def start_up_to_logfile(logfile_value)
150+
bs = BrowserStack::Local.new('dummy_key')
151+
bs.stub(:start_command_args, ->(*) { raise AbortAfterLogfile }) do
152+
begin
153+
# An existing, harmless executable as binarypath skips the binary download.
154+
bs.start('binarypath' => existing_executable, 'logfile' => logfile_value)
155+
rescue AbortAfterLogfile
156+
# expected: we intentionally stop before launching the binary
157+
end
158+
end
159+
end
160+
161+
def existing_executable
162+
['/bin/true', '/usr/bin/true'].find { |p| File.executable?(p) } || RbConfig.ruby
163+
end
164+
165+
def test_shell_metacharacters_in_logfile_path_are_not_executed
166+
Dir.mktmpdir do |dir|
167+
Dir.chdir(dir) do
168+
marker = File.join(dir, 'pwned')
169+
# Unix payload: close the single quote around @logfile, run touch, reopen.
170+
# Pre-fix this expands to: echo '' > 'log' ; touch <marker> ; echo 'x'
171+
payload = "log' ; touch #{marker} ; echo 'x"
172+
173+
start_up_to_logfile(payload)
174+
175+
refute File.exist?(marker),
176+
'shell metacharacters in the logfile path were executed (command injection)'
177+
end
178+
end
179+
end
180+
181+
def test_logfile_path_is_treated_as_a_literal_filename
182+
Dir.mktmpdir do |dir|
183+
logfile = File.join(dir, 'sub', 'my log.txt') # spaces + missing subdir
184+
start_up_to_logfile(logfile)
185+
186+
assert File.file?(logfile),
187+
'the logfile should be created as a literal path, even with spaces / a missing dir'
188+
assert_equal '', File.read(logfile), 'the logfile should be truncated to empty'
189+
end
190+
end
191+
end
192+
104193
class BrowserStackLocalBinaryTest < Minitest::Test
105194
def test_default_user_agent_contains_gem_name_and_version
106195
ua = BrowserStack::LocalBinary.new(auth_token: 'fake').instance_variable_get(:@user_agent)
@@ -159,6 +248,62 @@ def test_local_binary_accepts_proxy_conf
159248
assert_equal 8080, bin.instance_variable_get(:@proxy_port)
160249
end
161250

251+
# Regression: verify_binary must exec the binary directly, never via a shell,
252+
# so shell metacharacters in the cached-binary path cannot run commands (CWE-78).
253+
def test_verify_binary_does_not_interpret_shell_metacharacters_in_path
254+
marker = File.join(Dir.tmpdir, "bs_local_verify_injection_#{Process.pid}")
255+
File.delete(marker) if File.exist?(marker)
256+
injected = "/nonexistent;touch #{marker};echo BrowserStack Local version 9.9;#"
257+
258+
assert_equal false, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, injected)
259+
refute File.exist?(marker), 'shell metacharacters in the binary path were executed'
260+
ensure
261+
File.delete(marker) if marker && File.exist?(marker)
262+
end
263+
264+
# Stronger form of the above: a REAL binary living under a hostile-looking
265+
# directory name. Pins the array-form behaviour itself rather than just an
266+
# ENOENT, so a future "fix" that swapped the array form for a character
267+
# allowlist would fail here — the injected command must not run AND the
268+
# legitimate binary at that path must still verify.
269+
def test_verify_binary_runs_a_real_binary_at_a_path_containing_shell_metacharacters
270+
skip 'needs a POSIX shell to stand in for the binary' if Gem.win_platform?
271+
272+
marker = File.join(Dir.tmpdir, "bs_local_verify_dir_injection_#{Process.pid}")
273+
File.delete(marker) if File.exist?(marker)
274+
275+
base = Dir.mktmpdir('bs_local')
276+
dir = File.join(base, "h;touch #{marker};echo BrowserStack Local version 9.9;#")
277+
FileUtils.mkdir_p(dir)
278+
bin = File.join(dir, 'BrowserStackLocal')
279+
File.write(bin, "#!/bin/sh\necho 'BrowserStack Local version 9.9'\n")
280+
FileUtils.chmod(0755, bin)
281+
282+
assert_equal true, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, bin)
283+
refute File.exist?(marker), 'shell metacharacters in the binary path were executed'
284+
ensure
285+
File.delete(marker) if marker && File.exist?(marker)
286+
FileUtils.remove_entry(base) if base && File.directory?(base)
287+
end
288+
289+
# Same fix, benign side: a legitimate path containing spaces must still verify
290+
# (the shell used to split it and the check failed for every such user).
291+
def test_verify_binary_accepts_a_path_containing_spaces
292+
skip 'needs a POSIX shell to stand in for the binary' if Gem.win_platform?
293+
294+
base = Dir.mktmpdir('bs_local')
295+
dir = File.join(base, 'my binary dir')
296+
FileUtils.mkdir_p(dir)
297+
bin = File.join(dir, 'BrowserStackLocal')
298+
File.write(bin, "#!/bin/sh\necho 'BrowserStack Local version 9.9'\n")
299+
FileUtils.chmod(0755, bin)
300+
301+
assert_includes bin, ' '
302+
assert_equal true, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, bin)
303+
ensure
304+
FileUtils.remove_entry(base) if base && File.directory?(base)
305+
end
306+
162307
private
163308

164309
def with_host_config(host_os, host_cpu)

0 commit comments

Comments
 (0)