|
1 | 1 | require 'rubygems' |
2 | 2 | require 'minitest' |
3 | 3 | require 'minitest/autorun' |
| 4 | +require 'minitest/mock' |
| 5 | +require 'tmpdir' |
4 | 6 | require 'browserstack/local' |
5 | 7 |
|
6 | 8 | class BrowserStackLocalTest < Minitest::Test |
7 | 9 | def setup |
8 | 10 | @bs_local = BrowserStack::Local.new |
9 | 11 | end |
10 | 12 |
|
| 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 | + |
11 | 21 | def test_check_pid |
| 22 | + skip_without_credentials |
12 | 23 | @bs_local.start |
13 | 24 | refute_nil @bs_local.pid, 0 |
14 | 25 | end |
15 | 26 |
|
16 | 27 | def test_is_running |
| 28 | + skip_without_credentials |
17 | 29 | @bs_local.start |
18 | 30 | assert_equal true, @bs_local.isRunning |
19 | 31 | end |
20 | 32 |
|
21 | 33 | def test_multiple_binary |
| 34 | + skip_without_credentials |
22 | 35 | @bs_local.start |
23 | 36 | bs_local_2 = BrowserStack::Local.new |
24 | 37 | second_log_file = File.join(Dir.pwd, 'local2.log') |
@@ -96,11 +109,87 @@ def test_hosts |
96 | 109 | assert_match /localhost\,8080\,0/, @bs_local.command |
97 | 110 | end |
98 | 111 |
|
| 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 | + |
99 | 133 | def teardown |
100 | 134 | @bs_local.stop |
101 | 135 | end |
102 | 136 | end |
103 | 137 |
|
| 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 | + |
104 | 193 | class BrowserStackLocalBinaryTest < Minitest::Test |
105 | 194 | def test_default_user_agent_contains_gem_name_and_version |
106 | 195 | ua = BrowserStack::LocalBinary.new(auth_token: 'fake').instance_variable_get(:@user_agent) |
@@ -159,6 +248,62 @@ def test_local_binary_accepts_proxy_conf |
159 | 248 | assert_equal 8080, bin.instance_variable_get(:@proxy_port) |
160 | 249 | end |
161 | 250 |
|
| 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 | + |
162 | 307 | private |
163 | 308 |
|
164 | 309 | def with_host_config(host_os, host_cpu) |
|
0 commit comments