From 45b16dbc88d6d6653f04eafc0c07352a9406eb9c Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Thu, 22 Oct 2026 11:48:50 +0530 Subject: [PATCH 1/2] fix(security): keep launcher CLI downloads on https and browserstack.com [DEVA11Y-473] The launchers downloaded the CLI with `curl -L` and never checked where the redirect landed, so it could go to any host, over plain http. On Linux nothing verifies the binary's signature, and the risk Security accepted on DEVA11Y-473 assumes HTTPS from BrowserStack end to end. - Add `--proto =https --proto-redir =https` to the download curl call. - Refuse an archive whose final URL (url_effective, already captured) is not https on browserstack.com or a subdomain: delete the zip and exit 2, as a failed signature check does, before anything is extracted. - Add a `url` mode to tests/launcher-signature/run_tests.sh and a CI job for it. - Reword the README paragraph so it matches the launcher code. - Regenerate the launchers' self-update .sha256 sidecars. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/spm-smoke-test.yml | 13 ++++++ README.md | 2 +- scripts/bash/cli.sh | 32 +++++++++++++- scripts/bash/cli.sh.sha256 | 2 +- scripts/fish/cli.sh | 32 +++++++++++++- scripts/fish/cli.sh.sha256 | 2 +- scripts/zsh/cli.sh | 32 +++++++++++++- scripts/zsh/cli.sh.sha256 | 2 +- tests/launcher-signature/run_tests.sh | 60 +++++++++++++++++++++++++-- 9 files changed, 164 insertions(+), 13 deletions(-) diff --git a/.github/workflows/spm-smoke-test.yml b/.github/workflows/spm-smoke-test.yml index 8875f20..7a94e50 100644 --- a/.github/workflows/spm-smoke-test.yml +++ b/.github/workflows/spm-smoke-test.yml @@ -138,6 +138,19 @@ jobs: - name: Exercise verify_binary_signature (genuine / tampered / ad-hoc / unsigned) run: tests/launcher-signature/run_tests.sh matrix + # The launchers accept a CLI download only if curl's final URL is https on browserstack.com + # or a subdomain (DEVA11Y-473): on Linux there is no signature to check, so the accepted risk + # rests on that. Exercises is_allowed_download_url lifted from each launcher. Offline. + download-url-allowlist: + name: Launcher download URL allowlist + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Exercise is_allowed_download_url (allowed hosts / look-alikes / other schemes) + run: tests/launcher-signature/run_tests.sh url + # The CDN publishes no checksum, so the launchers and the SwiftPM plugin trust the # binary's code signature, pinned to BrowserStack's Team ID. If a release is ever # signed differently (new team, lapsed certificate, unsigned build), every user's diff --git a/README.md b/README.md index e76d82d..f65b46e 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ The SwiftPM plugin verifies the BrowserStack CLI binary's code signature before - **Windows:** the Authenticode signature must be valid and issued to BrowserStack, Inc. - **Linux:** the CLI build is not signed, so there is nothing to verify; the plugin relies on HTTPS alone. This residual risk is accepted on DEVA11Y-473 until the Linux build is signed or the API serves a digest. -The plugin downloads only over HTTPS and only follows download redirects to `https://` URLs on `browserstack.com`. The launcher scripts apply the same check on macOS, on every run before the binary is made executable or run: a binary that fails is refused and the cached download is discarded, so the next run fetches a fresh copy. On Linux they rely on HTTPS alone, under the same accepted risk. +The plugin downloads only over HTTPS and only follows download redirects to `https://` URLs on `browserstack.com`. The launcher scripts do the same: `curl` refuses any non-HTTPS request or redirect, and an archive whose final URL is not on `browserstack.com` or one of its subdomains is refused and discarded before it is extracted. The launchers also apply the macOS signature check, on every run before the binary is made executable or run: a binary that fails is refused and the cached download is discarded, so the next run fetches a fresh copy. On Linux they rely on HTTPS to `browserstack.com` alone, under the same accepted risk. Zsh ```zsh diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 8931cc5..ca0e49a 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -217,6 +217,21 @@ verify_binary_signature() { fi } +# Accept the archive only if curl's final URL (after redirects) is https on browserstack.com +# or a subdomain. --proto-redir already refuses non-https hops; this pins the host. Nothing +# checks the binary's signature on Linux, so the risk Security accepted on DEVA11Y-473 rests +# on the archive coming from BrowserStack over HTTPS end to end. +is_allowed_download_url() { + local url="$1" authority host + [[ "$url" == https://* ]] || return 1 + authority="${url#https://}" + authority="${authority%%[/?#]*}" + authority="${authority##*@}" + host=$(printf '%s' "${authority%%:*}" | tr '[:upper:]' '[:lower:]') + host="${host%.}" + [[ "$host" == "browserstack.com" || "$host" == *.browserstack.com ]] +} + download_binary() { local max_compressed=104857600 # 100 MB cap on the compressed download local max_decompressed=209715200 # 200 MB cap on the decompressed binary @@ -227,8 +242,10 @@ download_binary() { # unknown (chunked responses), so the explicit size check below backstops that case — # otherwise an attacker-controlled endpoint could exhaust the disk during download, before # the signature check and the decompression guard ever run (DEVA11Y-484 review). + # --proto/--proto-redir keep the request and every redirect on https; curl refuses any other + # scheme and exits 1 (DEVA11Y-473). local resolved_url curl_status=0 - resolved_url=$(curl -fR --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? + resolved_url=$(curl -fR --proto '=https' --proto-redir '=https' --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? if [[ $curl_status -ne 0 ]]; then # Distinguish a size abort from a network failure by what landed on disk rather than by # curl's exit code: --max-filesize is documented to exit 63, but measured against this @@ -238,6 +255,8 @@ download_binary() { partial_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $partial_size -ge $max_compressed ]]; then echo "BrowserStack CLI archive exceeds the maximum allowed download size (100 MB). Aborting." >&2 + elif [[ $curl_status -eq 1 ]]; then + echo "BrowserStack CLI download was redirected to a non-HTTPS URL; refusing it." >&2 else echo "BrowserStack CLI download failed (curl exited $curl_status)." >&2 fi @@ -250,6 +269,14 @@ download_binary() { return 1 fi + # Refuse before extracting anything. On a 304 url_effective is still the redirect target, + # so a cached zip is held to the same rule. Returns 2, like a failed signature check. + if ! is_allowed_download_url "$resolved_url"; then + echo "BrowserStack CLI download came from ${resolved_url:-}, not https on browserstack.com; refusing it." >&2 + rm -f "$BINARY_ZIP_PATH" + return 2 + fi + local compressed_size compressed_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $compressed_size -gt $max_compressed ]]; then @@ -340,6 +367,7 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then fi # Abort before executing the CLI if the download or its signature check failed -# (a failed signature check returns 2 from download_binary -> DEVA11Y-473/474). +# (a failed signature check or a download from outside browserstack.com returns 2 +# from download_binary -> DEVA11Y-473/474). download_binary || exit $? a11y_scan diff --git a/scripts/bash/cli.sh.sha256 b/scripts/bash/cli.sh.sha256 index 9b8c3d3..21a3042 100644 --- a/scripts/bash/cli.sh.sha256 +++ b/scripts/bash/cli.sh.sha256 @@ -1 +1 @@ -563fafba772a8fa23fc149b514592ea46961c9c202ab2c5a959f347e28839c5f cli.sh +db5d2d2a16bda96ebd9e777820c7e9167d629864153ad797c2b59e55cff203e3 cli.sh diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index 824936a..b8ee36b 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -229,6 +229,21 @@ verify_binary_signature() { fi } +# Accept the archive only if curl's final URL (after redirects) is https on browserstack.com +# or a subdomain. --proto-redir already refuses non-https hops; this pins the host. Nothing +# checks the binary's signature on Linux, so the risk Security accepted on DEVA11Y-473 rests +# on the archive coming from BrowserStack over HTTPS end to end. +is_allowed_download_url() { + local url="$1" authority host + [[ "$url" == https://* ]] || return 1 + authority="${url#https://}" + authority="${authority%%[/?#]*}" + authority="${authority##*@}" + host=$(printf '%s' "${authority%%:*}" | tr '[:upper:]' '[:lower:]') + host="${host%.}" + [[ "$host" == "browserstack.com" || "$host" == *.browserstack.com ]] +} + download_binary() { local max_compressed=104857600 # 100 MB cap on the compressed download local max_decompressed=209715200 # 200 MB cap on the decompressed binary @@ -239,8 +254,10 @@ download_binary() { # unknown (chunked responses), so the explicit size check below backstops that case — # otherwise an attacker-controlled endpoint could exhaust the disk during download, before # the signature check and the decompression guard ever run (DEVA11Y-484 review). + # --proto/--proto-redir keep the request and every redirect on https; curl refuses any other + # scheme and exits 1 (DEVA11Y-473). local resolved_url curl_status=0 - resolved_url=$(curl -fR --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? + resolved_url=$(curl -fR --proto '=https' --proto-redir '=https' --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? if [[ $curl_status -ne 0 ]]; then # Distinguish a size abort from a network failure by what landed on disk rather than by # curl's exit code: --max-filesize is documented to exit 63, but measured against this @@ -250,6 +267,8 @@ download_binary() { partial_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $partial_size -ge $max_compressed ]]; then echo "BrowserStack CLI archive exceeds the maximum allowed download size (100 MB). Aborting." >&2 + elif [[ $curl_status -eq 1 ]]; then + echo "BrowserStack CLI download was redirected to a non-HTTPS URL; refusing it." >&2 else echo "BrowserStack CLI download failed (curl exited $curl_status)." >&2 fi @@ -262,6 +281,14 @@ download_binary() { return 1 fi + # Refuse before extracting anything. On a 304 url_effective is still the redirect target, + # so a cached zip is held to the same rule. Returns 2, like a failed signature check. + if ! is_allowed_download_url "$resolved_url"; then + echo "BrowserStack CLI download came from ${resolved_url:-}, not https on browserstack.com; refusing it." >&2 + rm -f "$BINARY_ZIP_PATH" + return 2 + fi + local compressed_size compressed_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $compressed_size -gt $max_compressed ]]; then @@ -352,7 +379,8 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then fi # Abort before executing the CLI if the download or its signature check failed -# (a failed signature check returns 2 from download_binary -> DEVA11Y-473/474). +# (a failed signature check or a download from outside browserstack.com returns 2 +# from download_binary -> DEVA11Y-473/474). download_binary || exit $? a11y_scan diff --git a/scripts/fish/cli.sh.sha256 b/scripts/fish/cli.sh.sha256 index 5b9e554..b9b8e07 100644 --- a/scripts/fish/cli.sh.sha256 +++ b/scripts/fish/cli.sh.sha256 @@ -1 +1 @@ -8e8db46547aee7a326ef624342b17dd829db38fd690146a451ecc04a0a87b4a6 cli.sh +e17dd796c42a294c9ff519fe94d5bd20c0e33944d84e751479bdb8dd2ecc6379 cli.sh diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index cc56da5..202e5f9 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -228,6 +228,21 @@ verify_binary_signature() { fi } +# Accept the archive only if curl's final URL (after redirects) is https on browserstack.com +# or a subdomain. --proto-redir already refuses non-https hops; this pins the host. Nothing +# checks the binary's signature on Linux, so the risk Security accepted on DEVA11Y-473 rests +# on the archive coming from BrowserStack over HTTPS end to end. +is_allowed_download_url() { + local url="$1" authority host + [[ "$url" == https://* ]] || return 1 + authority="${url#https://}" + authority="${authority%%[/?#]*}" + authority="${authority##*@}" + host=$(printf '%s' "${authority%%:*}" | tr '[:upper:]' '[:lower:]') + host="${host%.}" + [[ "$host" == "browserstack.com" || "$host" == *.browserstack.com ]] +} + download_binary() { local max_compressed=104857600 # 100 MB cap on the compressed download local max_decompressed=209715200 # 200 MB cap on the decompressed binary @@ -238,8 +253,10 @@ download_binary() { # unknown (chunked responses), so the explicit size check below backstops that case — # otherwise an attacker-controlled endpoint could exhaust the disk during download, before # the signature check and the decompression guard ever run (DEVA11Y-484 review). + # --proto/--proto-redir keep the request and every redirect on https; curl refuses any other + # scheme and exits 1 (DEVA11Y-473). local resolved_url curl_status=0 - resolved_url=$(curl -fR --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? + resolved_url=$(curl -fR --proto '=https' --proto-redir '=https' --max-filesize "$max_compressed" -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" -w '%{url_effective}') || curl_status=$? if [[ $curl_status -ne 0 ]]; then # Distinguish a size abort from a network failure by what landed on disk rather than by # curl's exit code: --max-filesize is documented to exit 63, but measured against this @@ -249,6 +266,8 @@ download_binary() { partial_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $partial_size -ge $max_compressed ]]; then echo "BrowserStack CLI archive exceeds the maximum allowed download size (100 MB). Aborting." >&2 + elif [[ $curl_status -eq 1 ]]; then + echo "BrowserStack CLI download was redirected to a non-HTTPS URL; refusing it." >&2 else echo "BrowserStack CLI download failed (curl exited $curl_status)." >&2 fi @@ -261,6 +280,14 @@ download_binary() { return 1 fi + # Refuse before extracting anything. On a 304 url_effective is still the redirect target, + # so a cached zip is held to the same rule. Returns 2, like a failed signature check. + if ! is_allowed_download_url "$resolved_url"; then + echo "BrowserStack CLI download came from ${resolved_url:-}, not https on browserstack.com; refusing it." >&2 + rm -f "$BINARY_ZIP_PATH" + return 2 + fi + local compressed_size compressed_size=$(wc -c < "$BINARY_ZIP_PATH" 2>/dev/null || echo 0) if [[ $compressed_size -gt $max_compressed ]]; then @@ -351,7 +378,8 @@ if [[ $SUBCOMMAND == "register-pre-commit-hook" ]]; then fi # Abort before executing the CLI if the download or its signature check failed -# (a failed signature check returns 2 from download_binary -> DEVA11Y-473/474). +# (a failed signature check or a download from outside browserstack.com returns 2 +# from download_binary -> DEVA11Y-473/474). download_binary || exit $? a11y_scan diff --git a/scripts/zsh/cli.sh.sha256 b/scripts/zsh/cli.sh.sha256 index 63ef9e6..8a8586a 100644 --- a/scripts/zsh/cli.sh.sha256 +++ b/scripts/zsh/cli.sh.sha256 @@ -1 +1 @@ -3852bf2d8f2485f38c4d8f3eee30541c2130681599f380890e2ca9848bcf6776 cli.sh +8d8b22fdcd8a7b167e6461b80731b35ffe80a000f1283cdce46e4b9c16ad7c23 cli.sh diff --git a/tests/launcher-signature/run_tests.sh b/tests/launcher-signature/run_tests.sh index ac33b46..e7d852c 100755 --- a/tests/launcher-signature/run_tests.sh +++ b/tests/launcher-signature/run_tests.sh @@ -8,6 +8,9 @@ # run_tests.sh published the latest published macOS arm64 and x64 CLIs satisfy the # Team-pinned requirement, so a re-signed release (new team or # certificate) turns CI red instead of breaking every launcher. +# run_tests.sh url is_allowed_download_url accepts only https on browserstack.com +# or a subdomain, and refuses look-alike hosts, userinfo tricks +# and other schemes. Offline; runs on any OS. set -uo pipefail MODE="${1:-matrix}" @@ -18,9 +21,10 @@ trap 'rm -rf "$WORK"' EXIT pass=0 fail=0 -# Lift verify_binary_signature off a launcher (name() { ... } to the first bare }). +# Lift a function off a launcher (name() { ... } to the first bare }). +# extract [function name, default verify_binary_signature] extract() { - awk 'index($0,"verify_binary_signature() {")==1{p=1} p{print} p&&/^}$/{exit}' "$ROOT/scripts/$1/cli.sh" + awk -v start="${2:-verify_binary_signature}() {" 'index($0,start)==1{p=1} p{print} p&&/^}$/{exit}' "$ROOT/scripts/$1/cli.sh" } # Download and extract the latest CLI for an arch; prints the binary's path. @@ -85,10 +89,60 @@ run_published() { done } +# check_url +check_url() { + local launcher="$1" url="$2" want="$3" fn rc + fn=$(extract "$launcher" is_allowed_download_url) + if [[ -z "$fn" ]]; then + echo "FAIL | $launcher | is_allowed_download_url not found in scripts/$launcher/cli.sh" + fail=$((fail + 1)) + return + fi + bash -c "$fn"$'\n''is_allowed_download_url "$1"' _ "$url" && rc=0 || rc=$? + if [[ "$rc" == "$want" ]]; then + echo "PASS | $launcher | ${url:-} | rc=$rc" + pass=$((pass + 1)) + else + echo "FAIL | $launcher | ${url:-} | got rc=$rc want rc=$want" + fail=$((fail + 1)) + fi +} + +run_url() { + local l url + local allowed=( + "https://sdk-assets.browserstack.com/binary-linux-x64-1.63.0.zip" + "https://api.browserstack.com/sdk/v1/download_cli?os=linux&os_arch=x64" + "https://browserstack.com/binary.zip" + "https://SDK-Assets.BrowserStack.COM/binary.zip" + "https://sdk-assets.browserstack.com:443/binary.zip" + "https://sdk-assets.browserstack.com./binary.zip" + ) + local refused=( + "" + "http://sdk-assets.browserstack.com/binary.zip" + "ftp://sdk-assets.browserstack.com/binary.zip" + "https://evil.example/binary.zip" + "https://browserstack.com.evil.example/binary.zip" + "https://evilbrowserstack.com/binary.zip" + "https://browserstack.com@evil.example/binary.zip" + "https://sdk-assets.browserstack.com:443@evil.example/binary.zip" + "https://evil.example/@sdk-assets.browserstack.com/binary.zip" + "https://evil.example/?u=https://sdk-assets.browserstack.com/" + "https://evil.example#.browserstack.com" + "https://evil.example/.browserstack.com" + ) + for l in "${LAUNCHERS[@]}"; do + for url in "${allowed[@]}"; do check_url "$l" "$url" 0; done + for url in "${refused[@]}"; do check_url "$l" "$url" 1; done + done +} + case "$MODE" in matrix) run_matrix ;; published) run_published ;; - *) echo "usage: $0 [matrix|published]" >&2; exit 64 ;; + url) run_url ;; + *) echo "usage: $0 [matrix|published|url]" >&2; exit 64 ;; esac echo "----" From 20171cb9d6e6043e7904deecee6df9920b410740 Mon Sep 17 00:00:00 2001 From: Crash0v3rrid3 Date: Thu, 22 Oct 2026 12:02:14 +0530 Subject: [PATCH 2/2] test(extraction-guard): run the shell suite behind the new https/host checks [DEVA11Y-473] The DEVA11Y-484 suite serves its fixtures from plain http on 127.0.0.1 through a curl shim. The launchers' new `--proto '=https'` refused every fixture, and the host check would refuse the 127.0.0.1 url_effective, so 39 cases failed in "extraction-guard / shell launchers". - For a swapped URL, the shim widens --proto/--proto-redir to '=http,https' and reports the production redirect target as url_effective. download_binary itself is still unmodified. - The harness loads is_allowed_download_url, and its extraction step greps for both transport checks. Removing either one fails the suite (verified by mutation). - Cases 10 and 11 turn each shim relaxation off (SHIM_KEEP_PROTO, SHIM_REAL_URL_EFFECTIVE). The launcher must then refuse the plain-http download (exit 1) and the off-domain download (exit 2), leaving nothing on disk. Co-Authored-By: Claude Opus 5.5 --- tests/extraction-guard/README.md | 6 ++++ tests/extraction-guard/_shim/curl | 28 +++++++++++++++++ .../extraction-guard/test_shell_extraction.sh | 31 ++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/tests/extraction-guard/README.md b/tests/extraction-guard/README.md index 1d3567e..b6272ae 100644 --- a/tests/extraction-guard/README.md +++ b/tests/extraction-guard/README.md @@ -51,6 +51,12 @@ past those, the suite fails loudly instead of quietly testing nothing. `api.browserstack.com` URL to a local `python3 -m http.server` and passes every other argument through, so `--max-filesize`, `-L`, `-z` and the `bsdtar | head -c` pipeline all execute for real. No network, no credentials. +The one exception is the DEVA11Y-473 transport checks, which a plain-http +127.0.0.1 server can't satisfy. For a swapped URL the shim widens +`--proto`/`--proto-redir` to `=http,https` and reports the production redirect +target as `url_effective`. Cases 10 and 11 switch each of those off +(`SHIM_KEEP_PROTO=1`, `SHIM_REAL_URL_EFFECTIVE=1`) to prove both checks refuse, +and the extraction step greps for both. **The local server must be ours.** The port is derived from the PID, so an unrelated local service can already hold it. A bare "does anything answer?" probe diff --git a/tests/extraction-guard/_shim/curl b/tests/extraction-guard/_shim/curl index 09a8294..f6a5368 100755 --- a/tests/extraction-guard/_shim/curl +++ b/tests/extraction-guard/_shim/curl @@ -4,15 +4,43 @@ # test server. Every other argument (--max-filesize, -L, -o, -z, ...) is preserved, # so the security-relevant pipeline is exercised verbatim. Interception happens at # curl's CLI boundary; the shell function itself is never edited. +# +# Transport (DEVA11Y-473): download_binary restricts curl to https (--proto/--proto-redir +# '=https') and accepts the archive only if url_effective is https on browserstack.com. +# The fixture server is plain http on 127.0.0.1, so when it swaps the URL the shim also +# widens those two flags to '=http,https' and reports the production redirect target as +# url_effective, keeping the transport looking like production so the extraction guards +# behind it can be exercised. Two switches undo that, so the suite can prove the transport +# checks are wired into download_binary: +# SHIM_KEEP_PROTO=1 pass --proto/--proto-redir through unchanged +# SHIM_REAL_URL_EFFECTIVE=1 let curl report the real (127.0.0.1) url_effective set -u : "${REAL_CURL:?REAL_CURL must point at the real curl}" : "${TEST_DOWNLOAD_URL:?TEST_DOWNLOAD_URL must be set}" +swapped=0 +for a in "$@"; do + case "$a" in + https://api.browserstack.com/sdk/v1/download_cli*) swapped=1 ;; + esac +done + args=() +prev="" for a in "$@"; do case "$a" in https://api.browserstack.com/sdk/v1/download_cli*) a="$TEST_DOWNLOAD_URL" ;; esac + if [[ $swapped -eq 1 ]]; then + case "$prev" in + --proto|--proto-redir) + [[ "${SHIM_KEEP_PROTO:-0}" == 1 || "$a" != "=https" ]] || a="=http,https" ;; + -w) + [[ "${SHIM_REAL_URL_EFFECTIVE:-0}" == 1 || "$a" != "%{url_effective}" ]] \ + || a="https://sdk-assets.browserstack.com/binary-test-fixture.zip" ;; + esac + fi args+=("$a") + prev="$a" done exec "$REAL_CURL" "${args[@]}" diff --git a/tests/extraction-guard/test_shell_extraction.sh b/tests/extraction-guard/test_shell_extraction.sh index f702750..7558220 100755 --- a/tests/extraction-guard/test_shell_extraction.sh +++ b/tests/extraction-guard/test_shell_extraction.sh @@ -32,7 +32,7 @@ start_server "$FIXTURES" || exit 1 # leaves them undefined, and the function then dies with exit 127 on the first call # — which looks exactly like a guard rejection and would make every abort assertion # pass for the wrong reason. Load the whole dependency set. -DEPS=(_self_update_sha256 strip_quarantine verify_binary_signature download_binary) +DEPS=(_self_update_sha256 strip_quarantine verify_binary_signature is_allowed_download_url download_binary) load_download_binary() { local variant="$1" @@ -66,6 +66,13 @@ load_download_binary() { || { bad "$variant: extracted code lost the pre-emptive --max-filesize abort"; return 1; } grep -q 'max_decompressed=209715200' "$fn" \ || { bad "$variant: extracted code lost the 200 MB decompressed cap"; return 1; } + # The transport checks (DEVA11Y-473). The curl shim widens the protocol flags and + # fakes url_effective for the fixture server, so these greps (and cases 10/11) are + # what fail if either check is dropped. + grep -q -- "--proto '=https' --proto-redir '=https'" "$fn" \ + || { bad "$variant: extracted code lost the https-only curl restriction"; return 1; } + grep -q 'is_allowed_download_url "\$resolved_url"' "$fn" \ + || { bad "$variant: extracted code lost the browserstack.com download host check"; return 1; } # shellcheck disable=SC1090 source "$fn" @@ -248,6 +255,28 @@ for variant in bash zsh fish; do else echo " (skipping signature-gate case: needs macOS codesign)" fi + + # 10. https-only transport (DEVA11Y-473): with the protocol flags passed through, curl + # must refuse the plain-http fixture server, and nothing may be left on disk. + export SHIM_KEEP_PROTO=1 + run_case "legit.tar.gz" + unset SHIM_KEEP_PROTO + assert_status "$LAST_STATUS" 1 "$variant https-only: plain-http download refused with exit 1" + assert_contains "$LAST_STDERR" "non-HTTPS URL" \ + "$variant https-only: refused by the protocol restriction (not another guard)" + assert_absent "$LAST_CACHE/browserstack-cli.zip" "$variant https-only: no zip left behind" + assert_absent "$LAST_CACHE/browserstack-cli" "$variant https-only: no binary published" + + # 11. Download host check (DEVA11Y-473): with the real url_effective (127.0.0.1), the + # archive must be refused with exit 2 and discarded before extraction. + export SHIM_REAL_URL_EFFECTIVE=1 + run_case "legit.tar.gz" + unset SHIM_REAL_URL_EFFECTIVE + assert_status "$LAST_STATUS" 2 "$variant download host: off-domain download refused with exit 2" + assert_contains "$LAST_STDERR" "not https on browserstack.com" \ + "$variant download host: refused by the host check (not another guard)" + assert_absent "$LAST_CACHE/browserstack-cli.zip" "$variant download host: zip removed" + assert_absent "$LAST_CACHE/browserstack-cli" "$variant download host: no binary published" done summary