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
13 changes: 13 additions & 0 deletions .github/workflows/spm-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions scripts/bash/cli.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
Expand Down Expand Up @@ -217,6 +217,21 @@
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
Expand All @@ -227,8 +242,10 @@
# 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
Expand All @@ -238,6 +255,8 @@
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
Expand All @@ -250,6 +269,14 @@
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:-<unknown 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
Expand Down Expand Up @@ -340,6 +367,7 @@
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
2 changes: 1 addition & 1 deletion scripts/bash/cli.sh.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
563fafba772a8fa23fc149b514592ea46961c9c202ab2c5a959f347e28839c5f cli.sh
db5d2d2a16bda96ebd9e777820c7e9167d629864153ad797c2b59e55cff203e3 cli.sh
32 changes: 30 additions & 2 deletions scripts/fish/cli.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

export PATH="$PATH:/opt/homebrew/bin"
Expand Down Expand Up @@ -229,6 +229,21 @@
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
Expand All @@ -239,8 +254,10 @@
# 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
Expand All @@ -250,6 +267,8 @@
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
Expand All @@ -262,6 +281,14 @@
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:-<unknown 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
Expand Down Expand Up @@ -352,7 +379,8 @@
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

2 changes: 1 addition & 1 deletion scripts/fish/cli.sh.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8e8db46547aee7a326ef624342b17dd829db38fd690146a451ecc04a0a87b4a6 cli.sh
e17dd796c42a294c9ff519fe94d5bd20c0e33944d84e751479bdb8dd2ecc6379 cli.sh
32 changes: 30 additions & 2 deletions scripts/zsh/cli.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash -il

# Shell specific
Expand Down Expand Up @@ -228,6 +228,21 @@
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
Expand All @@ -238,8 +253,10 @@
# 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
Expand All @@ -249,6 +266,8 @@
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
Expand All @@ -261,6 +280,14 @@
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:-<unknown 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
Expand Down Expand Up @@ -351,7 +378,8 @@
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

Expand Down
2 changes: 1 addition & 1 deletion scripts/zsh/cli.sh.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3852bf2d8f2485f38c4d8f3eee30541c2130681599f380890e2ca9848bcf6776 cli.sh
8d8b22fdcd8a7b167e6461b80731b35ffe80a000f1283cdce46e4b9c16ad7c23 cli.sh
6 changes: 6 additions & 0 deletions tests/extraction-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/extraction-guard/_shim/curl
Original file line number Diff line number Diff line change
Expand Up @@ -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[@]}"
31 changes: 30 additions & 1 deletion tests/extraction-guard/test_shell_extraction.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Loading
Loading