-
Notifications
You must be signed in to change notification settings - Fork 10
LOC-6740: validate TLS chain and verify the binary before granting exec #29
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
b46601b
d0f1b91
803abb6
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 |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| name: PHP | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: ["master", "main"] | ||
| push: | ||
| branches: ["master", "main"] | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| test: | ||
| name: lint + phpunit | ||
| runs-on: ubuntu-latest | ||
| # 7.4 rather than 8.x: lib/ sets properties dynamically, which PHP 8.2 | ||
| # deprecates, and the library's own floor is php >= 5.3.19. phpunit 9.6 | ||
| # supports 7.3+. | ||
| container: php:7.4-cli | ||
| steps: | ||
| - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 | ||
|
|
||
| # Runs first and on its own: a syntax error must fail the build even if the | ||
| # suite cannot boot. | ||
| - name: Syntax check | ||
| run: | | ||
| php -l lib/Local.php | ||
| php -l lib/LocalBinary.php | ||
| php -l lib/LocalException.php | ||
| php -l tests/LocalTest.php | ||
| php -l tests/LocalBinaryTest.php | ||
|
|
||
| # git + unzip are not in the official php image, and Composer needs one of | ||
| # them to unpack downloaded packages (ext-zip is not built in either). | ||
| - name: Install dependencies | ||
| run: | | ||
| apt-get update -qq && apt-get install -y -qq --no-install-recommends git unzip >/dev/null | ||
| curl -sS https://getcomposer.org/installer | php | ||
| php composer.phar install --no-interaction --no-progress | ||
|
|
||
| # Excludes @group network — those tests reach badssl.com and the real S3 host. | ||
| - name: PHPUnit | ||
| run: ./vendor/bin/phpunit --exclude-group network |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,14 @@ | |
|
|
||
| class LocalBinary { | ||
|
|
||
| const DOWNLOAD_ATTEMPTS = 3; | ||
| const CONNECT_TIMEOUT = 10; | ||
| const DOWNLOAD_TIMEOUT = 300; | ||
|
|
||
| // Any real BrowserStackLocal build is tens of MB. A gateway error page or a | ||
| // truncated transfer is orders of magnitude smaller. | ||
| const MIN_BINARY_SIZE = 1048576; | ||
|
|
||
| public function __construct() { | ||
| $this->possible_binary_paths = array( | ||
| $this->server_home() . "/.browserstack", | ||
|
|
@@ -22,17 +30,18 @@ | |
|
|
||
| public function binary_path() { | ||
| $dest_parent_dir = $this->get_available_dirs(); | ||
| $dest_binary_name = "BrowserStackLocal"; | ||
| if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { | ||
| $dest_binary_name = $dest_binary_name. ".exe"; | ||
| } | ||
| $binary_path = $dest_parent_dir. "/". $dest_binary_name; | ||
| if(file_exists($binary_path)){ | ||
| return $binary_path; | ||
| } | ||
| else { | ||
| return $this->download_binary($dest_parent_dir); | ||
| $binary_path = $dest_parent_dir. "/". $this->dest_binary_name(); | ||
| if (file_exists($binary_path)) { | ||
| if ($this->verify_binary($binary_path)) { | ||
| $this->make_executable($binary_path); | ||
| return $binary_path; | ||
| } | ||
| // A cached file that is not a usable binary is discarded rather than | ||
| // executed — it may be a stored error page or a partial download. | ||
| // nosemgrep: php.lang.security.unlink-use.unlink-use -- basename is fixed by dest_binary_name(), no user input in the path | ||
| unlink($binary_path); | ||
Check warningCode scanning / Semgrep OSS Semgrep Finding: php.lang.security.unlink-use.unlink-use Warning
Using user input when deleting files with unlink() is potentially dangerous. A malicious actor could use this to modify or access files they have no right to.
|
||
|
|
||
| } | ||
| return $this->download_binary($dest_parent_dir); | ||
|
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. [for-human] Scope question — this PR closes the network substitution vector, but the same CWE-494 primitive remains locally reachable through the download directory, and that decision belongs to a human, not to another fix round. Evidence: In a world-writable
This is pre-existing, and this PR does not widen it — The call: widen scope here (e.g. drop
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. Not actioned in this round — escalating rather than deciding, which I think is what you are asking for. Recorded as follow-up #3 in the fix summary and the test report alongside the SHA-256 one, so it reaches the human with the ticket rather than dying in a PR thread. Your read is correct on both the mechanism and the blast radius, and I verified the two facts it rests on: My reasoning for not widening here, offered as input to that decision rather than as the decision:
My recommendation for the follow-up, if it helps:
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. [for-human] Carried forward from round 0, unchanged in this diff and still a human scope call — not a defect to fix in another round.
In a world-writable Pre-existing and not widened by this PR — The call: widen scope here (drop |
||
| } | ||
|
|
||
| private function server_home() { | ||
|
|
@@ -52,10 +61,20 @@ | |
| return empty($home) ? NULL : $home; | ||
| } | ||
|
|
||
| private function platform_url(){ | ||
| private function is_windows() { | ||
| return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; | ||
| } | ||
|
|
||
| private function dest_binary_name() { | ||
| return $this->is_windows() ? "BrowserStackLocal.exe" : "BrowserStackLocal"; | ||
| } | ||
|
|
||
| // protected so tests can point the download at a local fixture without | ||
| // patching installed source. | ||
| protected function platform_url(){ | ||
| if (PHP_OS == "Darwin") | ||
| return 'https://s3.amazonaws.com/browserStack/browserstack-local/BrowserStackLocal-darwin-x64'; | ||
| else if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') | ||
| else if ($this->is_windows()) | ||
| return 'https://s3.amazonaws.com/browserStack/browserstack-local/BrowserStackLocal.exe'; | ||
| if ((strtoupper(PHP_OS)) == "LINUX") { | ||
| if (PHP_INT_SIZE * 8 == 64) | ||
|
|
@@ -67,27 +86,109 @@ | |
|
|
||
| public function download_binary($path) { | ||
| $url = $this->platform_url(); | ||
| if (empty($url)) | ||
| throw new LocalException("No BrowserStack Local binary is available for platform " . PHP_OS); | ||
|
|
||
| if (!file_exists($path)) | ||
| mkdir($path, 0777, true); | ||
|
|
||
| $dest_binary_path = $path. '/'. $this->dest_binary_name(); | ||
| $last_error = "unknown error"; | ||
|
|
||
| $dest_binary_name = "BrowserStackLocal"; | ||
| if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { | ||
| $dest_binary_name = $dest_binary_name. ".exe"; | ||
| for ($attempt = 1; $attempt <= self::DOWNLOAD_ATTEMPTS; $attempt++) { | ||
| try { | ||
| $this->fetch_binary($url, $dest_binary_path); | ||
| if ($this->verify_binary($dest_binary_path)) { | ||
| // Execute permission is granted only after the download has been | ||
| // verified, never before. | ||
| $this->make_executable($dest_binary_path); | ||
| return $dest_binary_path; | ||
| } | ||
| $last_error = "downloaded file is not a valid BrowserStackLocal binary"; | ||
| } | ||
| catch (LocalException $e) { | ||
| $last_error = $e->getMessage(); | ||
| } | ||
| // Never leave an unverified file behind for a later run to pick up and execute. | ||
| // nosemgrep: php.lang.security.unlink-use.unlink-use -- basename is fixed by dest_binary_name(), no user input in the path | ||
| if (file_exists($dest_binary_path)) | ||
| unlink($dest_binary_path); | ||
Check warningCode scanning / Semgrep OSS Semgrep Finding: php.lang.security.unlink-use.unlink-use Warning
Using user input when deleting files with unlink() is potentially dangerous. A malicious actor could use this to modify or access files they have no right to.
|
||
|
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] This PR adds two net-new open code-scanning alerts — Both are false positives, and I checked rather than assumed: the unlink target is On the red Fix: dismiss both alerts as false-positive in the code-scanning UI, or annotate them so the dashboard for a security ticket does not accrue new open findings: // nosemgrep: php.lang.security.unlink-use.unlink-use -- fixed basename from dest_binary_name(), no user input in path
unlink($dest_binary_path);
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. Fixed in d0f1b91 — added the Your analysis matches mine: Agreed on the red
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] Both Those line numbers are the post-round-1 layout (they were 41 and 113 before the annotations were inserted), so the scan did re-run on the new code and still reports both. Two separate causes:
Fix — justification on its own comment line, bare directive immediately above the flagged statement: // Never leave an unverified file behind for a later run to pick up and execute.
// Path basename is fixed by dest_binary_name(); no user-controlled component.
if (file_exists($dest_binary_path))
// nosemgrep: php.lang.security.unlink-use.unlink-use
unlink($dest_binary_path);Same shape for line 41/42. Still cosmetic — both findings are genuine false positives — but worth getting right so a security ticket does not leave two open alerts behind. And to repeat from round 0 so it is not misread as a regression: the red |
||
| } | ||
| $dest_binary_path = $path. '/'. $dest_binary_name; | ||
| $file = fopen($dest_binary_path , "w+"); | ||
| $ch = curl_init(""); | ||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); | ||
|
|
||
| throw new LocalException("Error trying to download BrowserStack Local binary from " . | ||
| $url . " after " . self::DOWNLOAD_ATTEMPTS . " attempts. Last error: " . $last_error); | ||
| } | ||
|
|
||
| protected function fetch_binary($url, $dest_binary_path) { | ||
| $file = fopen($dest_binary_path, "w+"); | ||
| if ($file === false) | ||
| throw new LocalException("Unable to open " . $dest_binary_path . " for writing"); | ||
|
|
||
| $ch = curl_init(); | ||
| curl_setopt($ch, CURLOPT_URL, $url); | ||
| curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); | ||
| // The binary is executed on the user's machine, so the transport that | ||
| // delivers it must be authenticated: validate the certificate chain and | ||
| // that the certificate matches the host we asked for. | ||
| curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); | ||
|
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] This is a deliberate breaking change and it ships with no user-facing note anywhere. The PR description and the assessment on LOC-6740 both record Why it bites harder here than in Ruby: the download path has no This is also time-sensitive relative to follow-up #1: the note should land before the release tag, since the tag is what actually delivers the break to Fix: add a short "Upgrading" section to
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. Fixed in d0f1b91 — added an "Upgrading" section to The Ruby comparison was doing work it had not earned, and you are right that it argued against me: the gem told its users, this repo had no The section names the three things you asked for: the new behaviour, the literal I went with README over a new |
||
| curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); | ||
| curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); | ||
| // A redirect must not be able to downgrade the transfer to plaintext. | ||
| curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); | ||
| curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); | ||
| curl_setopt($ch, CURLOPT_FILE, $file); | ||
| $data = curl_exec ($ch); | ||
| curl_close ($ch); | ||
|
|
||
| curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::CONNECT_TIMEOUT); | ||
| curl_setopt($ch, CURLOPT_TIMEOUT, self::DOWNLOAD_TIMEOUT); | ||
| $result = curl_exec($ch); | ||
| $curl_errno = curl_errno($ch); | ||
| $curl_error = curl_error($ch); | ||
| $http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); | ||
| curl_close($ch); | ||
| fclose($file); | ||
| chmod($dest_binary_path, 0755); | ||
| return $dest_binary_path; | ||
|
|
||
| if ($result === false || $curl_errno !== 0) | ||
| throw new LocalException("Download failed (cURL error " . $curl_errno . "): " . $curl_error); | ||
|
|
||
| if ($http_status < 200 || $http_status >= 300) | ||
| throw new LocalException("Download failed with HTTP status " . $http_status); | ||
| } | ||
|
|
||
| // Confirms the bytes on disk are a platform executable of plausible size. | ||
| // This catches error pages, truncated transfers and empty files; it is not an | ||
| // authenticity check — that is the job of TLS chain validation in | ||
| // fetch_binary(). The file is deliberately not executed to test it. | ||
| protected function verify_binary($binary_path) { | ||
| // The retry loop stats the same path up to DOWNLOAD_ATTEMPTS times, so a | ||
| // cached stat entry from a previous attempt must not decide this one. | ||
| clearstatcache(true, $binary_path); | ||
| if (!is_file($binary_path)) | ||
| return false; | ||
| if (filesize($binary_path) < self::MIN_BINARY_SIZE) | ||
|
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] Scenario: attempt 1 stores a short S3 What I could not confirm: whether PHP's automatic stat-cache invalidation on Fix — one line makes it correct regardless of the answer, so it is cheaper than establishing which way the semantics fall: protected function verify_binary($binary_path) {
clearstatcache(true, $binary_path);
if (!is_file($binary_path))
return false;Same reasoning applies to the
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. Fixed in d0f1b91 — Taking your framing: I could not establish PHP's invalidation semantics either (same missing runtime), and the one-line guarantee costs less than settling the question. The scenario you describe is the one that worried me too — a short error body poisoning the size check for the next attempt would turn a transient S3 blip into a hard Worth noting the retry path still has no automated coverage — the existing tests exercise a single attempt. Recorded as a gap in the test report rather than papered over. |
||
| return false; | ||
|
|
||
| $handle = fopen($binary_path, "rb"); | ||
| if ($handle === false) | ||
| return false; | ||
| $magic = fread($handle, 4); | ||
| fclose($handle); | ||
| if ($magic === false || strlen($magic) < 4) | ||
| return false; | ||
|
|
||
| if ($this->is_windows()) | ||
| $valid = (substr($magic, 0, 2) === "MZ"); | ||
| else if (PHP_OS == "Darwin") | ||
| // Mach-O 64/32-bit little-endian, plus the universal ("fat") header. | ||
| $valid = in_array($magic, array("\xcf\xfa\xed\xfe", "\xce\xfa\xed\xfe", "\xca\xfe\xba\xbe"), true); | ||
| else | ||
| $valid = ($magic === "\x7f" . "ELF"); | ||
|
|
||
| return $valid; | ||
| } | ||
|
|
||
| private function make_executable($binary_path) { | ||
| clearstatcache(true, $binary_path); | ||
| if ($this->is_windows() || is_executable($binary_path)) | ||
| return true; | ||
| return @chmod($binary_path, 0755); | ||
| } | ||
|
|
||
| private function get_available_dirs() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,11 @@ | ||
| <phpunit> | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
| xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd" | ||
| bootstrap="vendor/autoload.php" | ||
| colors="true"> | ||
| <testsuites> | ||
| <testsuite name="local"> | ||
| <directory>tests</directory> | ||
| <directory suffix="Test.php">tests</directory> | ||
| </testsuite> | ||
| </testsuites> | ||
| </phpunit> | ||
|
|
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.
[blocking] This escape hatch does not work on this branch, and it is the only remedy offered to a user who cannot modify the system trust store.
Evidence —
binaryPathis accepted and then immediately discarded:LocalBinaryis constructed with no arguments and has no way to receive the caller's path, sobinary_path()goes down the download path regardless. For the exact user this README section addresses — behind a TLS-inspecting proxy — that download now raisesLocalException, so following this advice leaves them with the same hard failure they started with.This is a known open bug, not my inference: PR #27 ("bug: BrowserStack\Local does not pass along binary_path to BrowserStack\LocalBinary") exists to fix it and is still open.
Why this is blocking rather than a doc nit: a user with no admin rights on a locked-down machine cannot install a CA into the system trust store, so
binaryPathis their only listed option. Shipping it as a remedy sends them down a path that cannot work, and this text lands in the release that first enforces verification.Fix — pick one:
composer require browserstack/browserstack-local:<old>) as the stopgap.binaryPathplumbing here (or merge bug: BrowserStack\Local does not pass along binary_path to BrowserStack\LocalBinary #27 first) and keep the sentence — then it is true.If you take (2), please also add a test that
start(array('binaryPath' => …))does not download, since nothing currently covers it.