Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions lib/browserstack/local.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require 'browserstack/localbinary'
require 'browserstack/localexception'
require 'json'
require 'fileutils'

module BrowserStack

Expand Down Expand Up @@ -73,10 +74,16 @@ def start(options = {})
@binary_path
end

if @is_windows
system("echo > #{@logfile}")
else
system("echo '' > '#{@logfile}'")
# Create/truncate the logfile without a shell. The previous
# `system("echo ... > #{@logfile}")` passed @logfile to /bin/sh (or cmd.exe),
# so shell metacharacters in a caller-supplied logfile path executed as commands
# (CWE-78). File.write treats the path purely as a filename.
logfile_dir = File.dirname(@logfile)
FileUtils.mkdir_p(logfile_dir) unless File.directory?(logfile_dir)
begin
File.write(@logfile, "")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This fix correctly closes the logfile-creation shell sink. For completeness, note that the same @logfile value is still interpolated into a shell string elsewhere — start_command (further down this file):

cmd = "#{@binary_path} -d start -logFile '#{@logfile}' ... "

That string is only executed via IO.popen(start_command) in the else of if defined? spawn — i.e. on Ruby < 1.9, where spawn is not defined. On every supported Ruby (1.9+), start_command_args (the array form, no shell) is used instead, so this path is effectively dead and this PR does not regress it.

This is pre-existing and out of scope for this ticket (the finding here is the echo-into-system logfile sink, which is fully removed). Flagging only so a human can decide whether a follow-up ticket to harden/remove the legacy start_command string form (and its unquoted @key/@folder_path/proxy interpolations) is worthwhile. Not blocking.

rescue SystemCallError => e
raise BrowserStack::LocalException.new("Unable to open logfile: #{e.message}")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] File.write(@logfile, "") truncates to 0 bytes, whereas the old Unix echo '' > wrote a single newline and the Windows echo > wrote a line ending. Immaterial (the binary opens/writes the logfile itself) — just noting the behavioural delta for the record. The shell-free rewrite is correct and rescue SystemCallError correctly covers the Errno::* write failures. No change needed.

end

if defined? spawn
Expand Down
57 changes: 57 additions & 0 deletions test/browserstack-local-test.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
require 'rubygems'
require 'minitest'
require 'minitest/autorun'
require 'minitest/mock'
require 'tmpdir'
require 'browserstack/local'

class BrowserStackLocalTest < Minitest::Test
Expand Down Expand Up @@ -101,6 +103,61 @@ def teardown
end
end

# Regression tests for the logfile-creation step in Local#start (CWE-78).
# The logfile used to be created with `system("echo ... > #{@logfile}")`, which
# passed the caller-supplied path through a shell. These tests drive the public
# `start` entry point but abort just after the logfile step (a fake binarypath
# skips the download; stubbing start_command_args prevents launching the binary),
# so they need no credentials, network, or tunnel.
class BrowserStackLocalLogfileTest < Minitest::Test
class AbortAfterLogfile < StandardError; end

# Runs `start` with the given logfile value, aborting right after the logfile
# is created (before the real binary is spawned).
def start_up_to_logfile(logfile_value)
bs = BrowserStack::Local.new('dummy_key')
bs.stub(:start_command_args, ->(*) { raise AbortAfterLogfile }) do
begin
# An existing, harmless executable as binarypath skips the binary download.
bs.start('binarypath' => existing_executable, 'logfile' => logfile_value)
rescue AbortAfterLogfile
# expected: we intentionally stop before launching the binary
end
end
end

def existing_executable
['/bin/true', '/usr/bin/true'].find { |p| File.executable?(p) } || RbConfig.ruby
end

def test_shell_metacharacters_in_logfile_path_are_not_executed
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
marker = File.join(dir, 'pwned')
# Unix payload: close the single quote around @logfile, run touch, reopen.
# Pre-fix this expands to: echo '' > 'log' ; touch <marker> ; echo 'x'
payload = "log' ; touch #{marker} ; echo 'x"

start_up_to_logfile(payload)

refute File.exist?(marker),
'shell metacharacters in the logfile path were executed (command injection)'
end
end
end

def test_logfile_path_is_treated_as_a_literal_filename
Dir.mktmpdir do |dir|
logfile = File.join(dir, 'sub', 'my log.txt') # spaces + missing subdir
start_up_to_logfile(logfile)

assert File.file?(logfile),
'the logfile should be created as a literal path, even with spaces / a missing dir'
assert_equal '', File.read(logfile), 'the logfile should be truncated to empty'
end
end
end

class BrowserStackLocalBinaryTest < Minitest::Test
def test_default_user_agent_contains_gem_name_and_version
ua = BrowserStack::LocalBinary.new(auth_token: 'fake').instance_variable_get(:@user_agent)
Expand Down
Loading