Skip to content

fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484] - #25

Open
maunilm wants to merge 9 commits into
mainfrom
fix/DEVA11Y-484-bsdtar-size-limit
Open

fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484]#25
maunilm wants to merge 9 commits into
mainfrom
fix/DEVA11Y-484-bsdtar-size-limit

Conversation

@maunilm

@maunilm maunilm commented May 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a decompressed-size and entry-count guard to the CLI download/extract path, so a decompression bomb cannot exhaust developer or CI-runner disk.

Fixes DEVA11Y-484 (F-015, CWE-400, umbrella APPSEC-415).

Scope

This PR is deliberately narrowed to DEVA11Y-484's stated Remediation. Work that was previously in this branch — the regression suite, its CI workflow, and the Windows Expand-Archive backstop — was removed and is tracked in DEVA11Y-761, preserved on branch chore/DEVA11Y-484-followup-extraction-guard-harness. See Known gaps below.

Changes

Swift pluginPlugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift

  • startExtractionWatchdog polls the extraction directory every 50 ms while bsdtar runs and terminate()s it once the decompressed footprint crosses maxDecompressedBytes (200 MB) or maxArchiveEntries (10,000). A soft ceiling by design: peak disk ≈ maxBytes + (50 ms × write rate).
  • A post-exit footprintExceeded re-check catches a bomb that finishes inside one poll interval.
  • locateExecutable throws past 10,000 entries, per the ticket's ask.
  • maxCompressedBytes (100 MB) checked against response.expectedContentLength and the downloaded file's actual size.

Attached to extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single non-Windows extraction path — the archive is downloaded to a file and checksum-verified first, then extracted. The old streaming curl | bsdtar path that #37 deleted is gone, so there is no separate remote guard.

Launchersscripts/{bash,zsh,fish}/cli.sh

Verification

No automated suite ships with this PR (see Known gaps), so this was verified directly against the live download endpoint:

  • 27/27 assertions across bash/zsh/fish: real download exits 0 through fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's integrity check (38,017,898 B archive → 69,391,104 B binary, perms 0755); .tmp cleaned up after publish; re-run byte-identical; corrupt payload rejected; and the previously-cached binary's sha256 is unchanged after a rejected payload.
  • Guard fires: with a 1 MB cap the pipeline returns non-zero at exactly the cap; with the real 200 MB cap it is clean.
  • Swift guard, compiled standalone and driven against the real archive: real 200 MB cap does not flag (termStatus=0, 69,391,104 B, 1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus=15), bounding disk to 36 MB of 66 MB; maxEntries=0 flags on entry count.
  • Compressed cap: with a 1 MB cap curl aborts non-zero with nothing written; the real 100 MB cap passes the 38 MB archive.
  • pipefail save/restore verified in both directions.
  • swiftc -typecheck -parse-as-library clean; bash -n clean on all three launchers; all six .sha256 sidecars verify; self-update's own comparison matches for all three.

Headroom (CLI v1.52.1): largest platform archive is 41 MB compressed (2.4× under the 100 MB cap) and ~75.6 MB decompressed (2.65× under the 200 MB cap). The caps are duplicated in four places — they must move together when the CLI outgrows them.

Known gaps — owned, not hidden

  1. No automated regression coverage ships with this PR. The suite was descoped; nothing prevents a future refactor from silently dropping the caps. DEVA11Y-761 item 1, and the highest-value follow-up.
  2. Windows Expand-Archive is unguarded. No download cap enforcement mid-stream, no watchdog, no entry ceiling on that branch. Unchanged from main, but a real gap. DEVA11Y-761 item 3.
  3. The Swift compressed cap rejects after the transfer, not mid-stream. URLSession.download(from:) has no byte-level hook, so an oversized archive is stopped before checksum/extract/exec but peak temporary disk during transfer is not bounded. Needs a URLSessionDownloadDelegate cancelling in didWriteData. The launchers do abort during transfer. DEVA11Y-761.
  4. The launchers have no entry-count equivalent to the plugin's maxArchiveEntries. In -O mode an archive of millions of empty entries streams ~0 bytes, so head -c never fires; disk stays bounded but bsdtar still parses every entry. Open for review discussion — no cheap mechanism in -O mode.
  5. Linux launcher path untested. Verified on macOS/arm64 against the live endpoint; CI has no Linux launcher job.

Note on the ticket's threat model

DEVA11Y-484 states the download has "no TLS, per scope.md:65". The URL in code is https:// and the live endpoint serves HTTPS with a 302 to https://sdk-assets.browserstack.com, which weakens the stated MitM reachability behind the AV:N / CVSS 5.3 rating. The BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL override remains a genuine vector, so the fix stands — but the premise as written is inaccurate.

Refs DEVA11Y-484, DEVA11Y-761, APPSEC-415.

…mb DoS [DEVA11Y-484]

CWE-400 / OWASP A05. bsdtar was invoked with no decompressed-size or
entry-count limit in both the Swift SPM plugin and the bash/zsh/fish CLI
wrappers, so an attacker who can influence the download URL (the
HTTPS-only --download-url / BROWSERSTACK_A11Y_CLI_DOWNLOAD_URL override,
or TLS interception) could serve a decompression bomb that exhausts the
developer/CI disk.

Swift plugin (BrowserStackAccessibilityLint.swift):
- curl now passes --max-filesize (100 MB) to cap the compressed download.
- A background watchdog terminates bsdtar once the *decompressed* footprint
  on disk exceeds 200 MB (a pipe-level cap would only bound compressed
  bytes, which is useless against a bomb). Applied to both the remote and
  local extraction paths.
- locateExecutable now bounds enumeration at 10,000 entries.

Shell wrappers (bash/zsh/fish cli.sh):
- curl --max-filesize caps the compressed download.
- bsdtar output is piped through `head -c` (200 MB) with pipefail so an
  oversized archive aborts instead of filling the disk.

Real CLI artifact is ~34 MB compressed / ~64 MB decompressed, so the caps
leave ~3x headroom and do not affect legitimate downloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maunilm
maunilm requested a review from a team as a code owner May 29, 2026 12:28
maunilm and others added 2 commits June 2, 2026 16:44
…on guard [DEVA11Y-484]

Adds local integration tests (no mocks) that exercise the decompression-bomb
guards against real curl/bsdtar/head and the real Swift watchdog, plus hardens
the guard itself based on what the tests surfaced.

Guard hardening (Plugins/BrowserStackAccessibilityLint.swift):
- The watchdog now also terminates bsdtar on an entry-count ceiling, closing the
  "millions of tiny files" bomb that stays small on disk (previously only
  locateExecutable caught it, after the fact).
- Added a post-extraction footprint check so detection is deterministic on fast
  disks: a bomb that finishes decompressing within a single 200ms poll interval
  is now caught and cleaned up rather than slipping past the live watchdog.
- Refactored the guard into a self-contained, marked block of free functions so
  it can be mirrored and drift-checked.

Tests (scripts/test/, run via run_tests.sh):
- Shell: extracts the REAL download_binary from bash/zsh/fish verbatim and runs it
  against a local server (only the hardcoded URL is redirected, via a curl shim).
- Swift: a mirror harness compiles the guard block verbatim and drives real
  curl/bsdtar; check_drift.sh fails CI if the mirror diverges from the plugin
  (SwiftPM command plugins can't be imported by a test target).
- Scenarios: legit (downloads/extracts/runs), 400MB bomb, 20k-entry bomb,
  oversized (>100MB) download, corrupt archive, multi-file, missing URL.
- Fixtures are bounded (≤400MB, gitignored) and bomb tests use a small cap, so a
  regressed guard can never exhaust the disk. Full run ~9s, disk usage flat.
- CI: .github/workflows/extraction-guard-tests.yml runs the suite on macOS for PRs
  touching the download/extract path.

53/53 assertions green locally; real production artifact (34MB/64MB) verified to
pass through the new extraction path and run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… live termination [DEVA11Y-484]

Addresses gaps found by stress-testing the guard rather than just asserting the
happy path:

- Measured overshoot: at a 200ms poll, bsdtar could write ~270-380MB past the cap
  on a fast disk before the watchdog tripped (the cap was far softer than the
  "200 MB" message implied). Tightened the poll to 50ms — a 10MB cap now peaks at
  ~34MB and a 2GB bomb is killed at ~224MB. Documented the cap as an explicit SOFT
  ceiling whose purpose is preventing disk *exhaustion*, not exact byte enforcement.
- Windows Expand-Archive path was completely unguarded. Added a platform-agnostic
  post-extraction footprint backstop in the common path (typecheckable on macOS)
  so Windows rejects + cleans up a bomb before the binary is used.
- Strengthened tests to assert the LIVE watchdog fires (bsdtar SIGTERM, status 15)
  and that peak disk stays bounded below the bomb size — previously the bomb tests
  would have passed even if only the post-extraction check worked (which would let
  a multi-GB bomb fill the disk).
- Added test_large_bomb.sh (opt-in via DEVA11Y_DEEP=1): proves a 2GB bomb is
  bounded to ~224MB. Kept out of the default CI run to keep it fast/bounded.
- README now documents the real limitations: soft cap + overshoot, Windows is
  post-hoc only, the Swift suite tests a mirror (not the compiled plugin) with the
  call sites typecheck-only, and locateExecutable's cap is defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
maunilm and others added 5 commits August 27, 2026 13:31
Brings the branch up to date with main (e4bb5dc) to clear the merge conflict
and regenerates the self-update checksum sidecars.

Conflicts (4) and how they were resolved:

* Plugins/.../BrowserStackAccessibilityLint.swift — main (#32, DEVA11Y-482)
  refactored prepareArtifact to extract into a staging directory and atomically
  publish it to the version directory. This branch's decompression-bomb backstop
  was written against the old flow and checked versionDirectory after extraction.
  Kept main's staging/publish architecture and moved the backstop to check
  stagingDirectory *before* publishVersionDirectory, cleaning up staging on
  rejection. This is stricter than the original: a rejected archive now never
  becomes a visible version directory at all.

* scripts/{bash,zsh,fish}/cli.sh — main (#36, DEVA11Y-752) added strip_quarantine
  and tightened chmod 0775 -> 0755; this branch added the compressed/decompressed
  size caps. Both were kept: curl --max-filesize plus the bsdtar | head -c guard
  and the size assertion, then main's chmod 0755 and strip_quarantine. main's
  chained `&&` is unnecessary here because the size guard exits non-zero on
  failure, so reaching the chmod means extraction succeeded. Took main's 0755
  (dropping group-write) rather than reverting its hardening.

Test fixes required by the merge:

* test_shell_extraction.sh asserted chmod 775; updated to 755 to match main.

* load_download_binary awk-extracts only download_binary() and sources it in
  isolation, so the newly-called strip_quarantine was undefined and every
  success-path case exited 127 after an otherwise correct extraction. The loader
  now extracts strip_quarantine too, with a faithfulness check for it.

Verification on this merge commit:

* scripts/test/run_tests.sh — ALL GREEN: drift check passed, shell wrappers
  36/36, Swift plugin guard 19/19 (baseline pre-merge was also 36/36 and 19/19).
* Merged plugin typechecks clean (swiftc -typecheck -parse-as-library against
  the PackagePlugin API), matching main's baseline.
* All six scripts/*/{cli,spm}.sh.sha256 sidecars verify with sha256sum -c;
  the three cli.sh sidecars were regenerated (they failed before this commit,
  which would have broken the verify-selfupdate-checksums gate added in #30).
* bash -n clean on all three wrappers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… [DEVA11Y-484]

The verify-selfupdate-checksums gate (added on main in #30, DEVA11Y-475) globs
`scripts/**/*.sh` with globstar and requires a committed `<script>.sha256`
sidecar for every match. This branch added seven support scripts under
scripts/test/ — run_tests.sh, check_drift.sh, make_fixtures.sh, lib/assert.sh,
test_{shell,swift}_extraction.sh, test_large_bomb.sh — none of which has a
sidecar, so the gate failed as soon as main was merged in.

Generating sidecars for them would be wrong: that workflow exists because
self-update *fetches each launcher script from main and verifies it against its
sidecar*. These test scripts are never fetched or verified at runtime, so a
sidecar would assert a protection that does not exist, and every future edit to
a test script would need a checksum regen.

Moving the suite under tests/ fixes it at the source and needs no change to the
security workflow: scripts/ once again contains only the six self-updating
launchers (bash/zsh/fish x cli.sh,spm.sh), all of which have matching sidecars.
It also matches the convention main established in #35, which put its own
harnesses (and tests/spm/scripts/run-a11y-scan.sh) under tests/.

The move is path-transparent: every script resolves paths via
HERE="$(dirname "${BASH_SOURCE[0]}")" and REPO="$HERE/../..", and
tests/extraction-guard/../.. is still the repo root, so no script body changed.

Updated references:
* .github/workflows/extraction-guard-tests.yml — path filter and the run: line
* Plugins/.../BrowserStackAccessibilityLint.swift — drift-mirror doc comments
* swift-harness/Sources/ExtractionHarness/Guard.swift — same doc comments
* tests/extraction-guard/README.md — invocation path
* tests/README.md — added an index row for the suite, labelled as a security
  regression suite rather than a consumer-project harness

Verification after the move:
* bash tests/extraction-guard/run_tests.sh — ALL GREEN: drift check passed,
  shell wrappers 36/36, Swift plugin guard 19/19.
* verify-selfupdate-checksums logic replicated locally: scripts/**/*.sh now
  matches exactly the six launchers, every sidecar present and matching — gate
  passes with the workflow file unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR had grown to +1044/-7 across 18 files for a ticket sized XS. Only 216 of
those lines were the production fix; the rest was test infrastructure and CI the
ticket never asked for. Narrowed to exactly what DEVA11Y-484's Remediation
section specifies, so the security change is reviewable on its own.

Kept — the ticket's three requirements:

1. Swift streaming guard, 200 MB decompressed. "interpose a byte-counting
   wrapper ... that calls Process.terminate() on bsdtar if a threshold is
   crossed" — implemented as startExtractionWatchdog on both bsdtar paths
   (remote stream and local archive), with a post-exit footprint re-check to
   catch a bomb that completes inside one poll interval.
2. Shell guard. "pipe the curl output through head -c 209715200 (200 MB)" —
   implemented verbatim in all three launchers, with pipefail so bsdtar's
   SIGPIPE surfaces as a failure, plus an explicit size assertion.
3. locateExecutable entry cap. "the locateExecutable enumerator should impose a
   maximum file-count cap" — maxArchiveEntries = 10_000, throws when exceeded.

Removed — out of scope, deferred (all preserved on
chore/DEVA11Y-484-followup-extraction-guard-harness):

* tests/extraction-guard/ — the 13-file, ~799-line regression harness (shell
  variants, Swift mirror harness, drift check, fixture generator).
* .github/workflows/extraction-guard-tests.yml — the CI job that runs it.
* Compressed-size cap: maxCompressedBytes and curl --max-filesize in the plugin,
  and the same in all three launchers. The ticket asks for a 200 MB
  *decompressed* cap; capping the wire size is separate hardening. The curl
  invocation now matches main byte-for-byte.
* The prepareArtifact-level footprintExceeded backstop. It covered the Windows
  Expand-Archive path, which the ticket did not scope (it targets the bsdtar
  paths). Windows therefore remains unguarded — carried on the follow-up branch.
* tests/README.md index row and the plugin's drift-mirror comment, both of which
  referenced the removed harness.

The three cli.sh.sha256 sidecars were regenerated after dropping --max-filesize.

Verification (the harness is gone, so this was done directly):
* Real endpoint, merged download_binary, macos/arm64: exit 0, 38,017,898 B
  archive -> 69,391,104 B binary, perms 755, not truncated.
* Guard proven to fire: same archive with a 1 MB cap gives pipeline status 1 at
  exactly the cap, so the abort path triggers; with the real 200 MB cap the
  pipeline is clean. Worst-case platform is macos/x64 at ~75.6 MB decompressed,
  2.65x headroom.
* swiftc -typecheck -parse-as-library against the PackagePlugin API: clean.
* bash -n clean on all three launchers.
* verify-selfupdate-checksums logic replicated locally: all six sidecars present
  and matching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves the conflict introduced by #37 (DEVA11Y-473/474, "verify downloaded CLI
binary integrity before exec"), which landed on main after the previous merge and
rewrote the same download/extract paths this branch guards.

Conflicts (7): the plugin, the three cli.sh launchers, and their three sidecars.

Plugin — took main's side wholesale. #37 deleted extractRemoteArchive entirely,
replacing the streaming `curl | bsdtar` with download-to-file ->
verifyArchiveChecksum -> extractLocalArchive (or unzip on Windows), precisely so
the payload can be verified before it is extracted and executed. This branch's
watchdog on that streaming path therefore no longer has a path to guard, so the
59-line block was dropped rather than reinstated.

The DEVA11Y-484 guard is unaffected in substance and is now simpler: the watchdog
already sits on extractLocalArchive, which after #37 is the single non-Windows
extraction path for both remote and local archives. The locateExecutable
10_000-entry cap is untouched. Windows' unzip path remains unguarded, as before
(tracked on DEVA11Y-761).

Launchers — combined both changes rather than picking a side:
* Kept #37's `curl -fR -z ... -w '%{url_effective}'` with its `return 1`,
  verify_binary_integrity with its `return $?` passthrough, and the
  stage-to-.tmp / chmod / `mv -f` / strip_quarantine publish chain.
* Moved this branch's `head -c "$max_decompressed"` guard onto that staged path
  (`${BINARY_PATH}.tmp`) instead of `$BINARY_PATH`. This matters: writing the cap
  directly to $BINARY_PATH would reintroduce exactly the bug #37 fixed — a
  rejected payload truncating a previously-good cached binary. The rejection path
  now removes only the .tmp file.
* Switched the guard's failure from `exit 1` to `return 1`, matching #37's
  contract (the call site is `download_binary || exit $?`, which also preserves
  the distinct exit 2 for an integrity mismatch). This removes the behaviour
  divergence the previous merge had introduced.

Sidecars regenerated for all three launchers.

Verification on this merge commit:
* 27/27 assertions across bash/zsh/fish against the live download endpoint: real
  download exits 0 through #37's integrity check, binary 69,391,104 B at perms
  0755, .tmp cleaned up after publish, re-run byte-identical, corrupt payload
  rejected — and, critically, the previously-cached binary SURVIVES a rejected
  payload with an unchanged sha256, confirming #37's protection is intact rather
  than undone by the cap.
* swiftc -typecheck -parse-as-library against the PackagePlugin API: clean.
* bash -n clean on all three launchers.
* All six sidecars verify; self-update's own comparison (awk first field vs
  shasum -a 256) matches for all three.
* Confirmed no #37 feature lost: verify_binary_integrity, mv -f, url_effective
  and `curl -fR -z` all present at main's counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…11Y-484]

Two comments this branch added still described the streaming curl | bsdtar path
that #37 (DEVA11Y-473/474) deleted, so they pointed at code that no longer exists:

* the extractLocalArchive call-site said "same rationale as the remote path"
* the EXTRACTION GUARD block's rationale was framed around capping the
  "curl→bsdtar pipe"

Reworded to describe what the guard actually attaches to now, and stated
explicitly that extractLocalArchive is the single non-Windows extraction path
since #37 (download to file, checksum-verify, then extract) and that Windows'
unzip path has no streaming guard.

Comment-only; no behaviour change. Guard block re-verified against the real CLI
archive after the edit: real 200 MB cap does not flag (termStatus 0, 69,391,104 B,
1 entry); a 5 MB cap flags and SIGTERMs bsdtar mid-stream (termStatus 15, disk
bounded to 36 MB of 66 MB); maxEntries=0 flags on entry count. swiftc
-typecheck -parse-as-library clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Crash0v3rrid3 Crash0v3rrid3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Multi-agent code review — decompression-bomb guard (DEVA11Y-484)

Reviewed across security/adversarial, Swift concurrency, and shell-correctness lenses plus first-party verification against the PR head. The guard that ships is correctly implemented — but the PR description overstates the implementation on two verified counts, and I'd hold merge until those are reconciled.

What's correct (verified — no action needed)

  • Swift concurrency is sound: watchdog thread lifecycle has no race/leak, terminate()waitUntilExit()removeItem ordering is safe, ExtractionLimitState's NSLock is correct, and forwardExit is -> Never/exit (no fall-through).
  • Shell logic is correct: the bomb is genuinely caught (head -c cap → SIGPIPE → 141 via pipefail, with -ge as a backstop), legit downloads pass, and local x=$? captures the pipeline status correctly.
  • scripts/fish/cli.sh is #!/usr/bin/env bash -il (a bash script), so the guard syntax is intact in all three wrappers.
  • Path traversal / symlink escape is blocked by libarchive defaults (bsdtar -x without -P) — writes stay inside the polled directory.

Blocking / high-priority

1. (P1) The described test suite and CI workflow do not exist in the PR. The description details scripts/test/, run_tests.sh, check_drift.sh, .github/workflows/extraction-guard-tests.yml, and "53/53 assertions green." At the PR head none of these exist — scripts/ contains only the wrappers, and the only workflows present are Semgrep.yml, spm-smoke-test.yml, and verify-selfupdate-checksums.yml. A security-critical guard would merge with no regression protection. Please commit the suite + CI, or remove the claims from the description.

2. (P2) No compressed-download size cap exists, despite the Summary claiming one. The Summary states "curl --max-filesize (100 MB) caps the compressed download," but:

  • scripts/*/cli.sh — the download curl (curl -fR -z … -L … -o …) has no --max-filesize.
  • BrowserStackAccessibilityLint.swiftdownload(...) uses URLSession.shared.download(from: url) with no Content-Length/byte limit.

In the fix's own threat model (MITM of the HTTPS endpoint, or an attacker-controlled HTTPS override URL), a multi-GB compressed payload exhausts disk during download — before checksum or extraction — bypassing the entire decompression guard. Please add --max-filesize to the curl download and a byte cap to the Swift download, or strike the claim.

3. (P2) Windows extraction path is unguarded — see the inline note; either guard it or track it as an explicit follow-up.

Lower priority

Inline P3 comments cover: shell entry-count asymmetry, set +o pipefail toggled unconditionally, .tmp cleanup on chmod/mv failure, poll-interval doc drift (50 ms vs 200 ms), SIGTERM-only kill, extractionFootprint fail-open + hidden-file inconsistency, missing private on the new decls, and the libarchive-containment assumption.

Verdict: Not ready — the core guard is correct, but the compressed-download cap (#2) and the test/CI suite (#1) are described but absent, and Windows is unguarded (#3). Land the missing pieces or correct the description and consciously accept the gaps.

🤖 Multi-agent review via Claude Code (compound-engineering). Posted as comments, not a formal request-changes.

Comment thread scripts/bash/cli.sh
# that as a failure. Because the cap applies to ${BINARY_PATH}.tmp and publication is a
# later mv, a rejected bomb leaves any previously-cached binary untouched.
set -o pipefail
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — shell path lacks the Swift entry-count guard. In -O mode an archive of millions of tiny/empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded (good), but bsdtar still parses every entry (CPU/time drain) and a near-empty bogus payload passes the size check and gets chmod+mv'd into the cache. The Swift path guards this with maxArchiveEntries = 10_000; the wrappers have no equivalent. Consider an entry ceiling or --max-time on extraction.

(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)

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.

Acknowledged as a real gap, and deliberately not fixed in this PR — flagging rather than silently skipping.

Your analysis is right: in -O mode an archive of millions of empty entries streams ~0 bytes to stdout, so head -c never fills and never SIGPIPEs bsdtar. Disk stays bounded, but bsdtar parses every entry and a near-empty payload passes the size check and gets published. The plugin's maxArchiveEntries = 10_000 has no wrapper equivalent.

Why it is not in this commit: there is no cheap, correct mechanism in -O mode. The options I considered:

  • bsdtar -tf pre-pass to count entries — doubles archive parsing and is itself unbounded on a millions-of-entries archive, so it moves the CPU drain rather than removing it.
  • --max-time on extraction — a wall-clock proxy for an entry count; flaky on slow CI runners and does not actually bound entries.
  • Extract to a directory instead of -O so the footprint is measurable like the plugin's — the correct fix, but that is a real change to the wrapper's extraction model, and the wrappers are what self-update ships to every user from main. Not something I want to land in the same PR as the guard, untested on Linux.

So: tracked as a follow-up on DEVA11Y-761 with your reasoning quoted, and listed under Known gaps item 4 in the rewritten PR description so it is owned rather than invisible.

Worth noting the residual is narrower than it was: the compressed-download cap added in 2c5fba8 (curl --max-filesize + post-download size check) bounds how large such an archive can be in the first place, so the CPU drain is capped at parsing a ≤100 MB archive rather than an unbounded one. That does not close the gap, but it does bound it.

Happy to take the "extract to a directory" approach as its own PR if you would rather not carry the gap.

Comment thread scripts/bash/cli.sh Outdated
set -o pipefail
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=$?
set +o pipefail

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — set +o pipefail is toggled unconditionally. Neither wrapper sets pipefail globally today, so this is safe now, but it disables the option outright rather than restoring the prior state. If a global set -o pipefail is ever added to these scripts, this line will silently switch it off for everything after download_binary. Prefer save/restore, e.g. capture set +o | grep pipefail before and restore it after.

Also note: if chmod/mv fail on the happy path just below, ${BINARY_PATH}.tmp is left on disk — the rm -f cleanup only runs on the size-rejection branch. Minor, but inconsistent with the explicit cleanup above.

(Applies identically to scripts/zsh/cli.sh and scripts/fish/cli.sh.)

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.

Both fixed in 2c5fba8, in all three launchers.

pipefail — now saved and restored rather than cleared:

local pipefail_was_set=0
case "$(set +o)" in *"-o pipefail"*) pipefail_was_set=1 ;; esac
set -o pipefail
bsdtar … | head -c "$max_decompressed" > "${BINARY_PATH}.tmp"
local extract_status=$?
[[ $pipefail_was_set -eq 1 ]] || set +o pipefail

Verified both directions: with set -o pipefail in the caller it is still set after download_binary returns; with it off, it stays off.

.tmp on the happy path — good catch, the asymmetry was real. chmod/mv are now guarded with cleanup on failure instead of a bare && chain:

if ! { chmod 0755 "${BINARY_PATH}.tmp" && mv -f "${BINARY_PATH}.tmp" "$BINARY_PATH"; }; then
  echo "BrowserStack CLI: failed to publish the downloaded binary." >&2
  rm -f "${BINARY_PATH}.tmp"
  return 1
fi
strip_quarantine

process.terminate()
break
}
Thread.sleep(forTimeInterval: 0.05)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — poll-interval doc drift. This sleeps every 50 ms (0.05), but the PR description and the overshoot math in the docstring above refer to a "200 ms poll interval" — off by 4×. Either bump this to 0.2 or correct the description/comment so the documented worst-case footprint (maxBytes + pollInterval × writeRate) matches reality.

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.

Fixed in 2c5fba8. You were right that it was off by 4x — and the drift was in the docstring rather than the code, so I corrected the docs to the real 50 ms rather than slowing the poll:

/// the limit before it is killed, so peak disk use is roughly `maxBytes + (50 ms x disk
/// write rate)` — the poll interval below is 50 ms.

Kept 50 ms because it is what the measurements in the description were actually taken at: against the 400 MB fixture the watchdog bounded peak disk to 58 MB, and re-verified on this head against the real 38 MB archive with a 5 MB cap it bounds to 36 MB of 66 MB. Widening to 200 ms would loosen that overshoot 4x for no benefit.

The PR description has also been rewritten (it was stale in several places — see the top-level reply).

while process.isRunning {
if let reason = footprintExceeded(at: directory, maxBytes: maxBytes, maxEntries: maxEntries) {
state.markExceeded(reason)
process.terminate()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — SIGTERM only, no escalation. terminate() sends SIGTERM once and the loop breaks. bsdtar doesn't trap SIGTERM so this is fine in practice, but if it's ever slow to die (blocked I/O), waitUntilExit() on the main thread blocks with no SIGKILL fallback. Low impact; consider a bounded wait + kill(pid, SIGKILL) escalation for robustness.

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.

Agreed on the analysis, and taking your own read that it is low impact — not changing it in this PR.

For the record on why: bsdtar does not trap SIGTERM, so in practice it dies immediately; the watchdog breaks straight after terminate() and the loop condition is while process.isRunning, so the thread exits cleanly with no leak. The theoretical hang needs bsdtar blocked in uninterruptible I/O, in which case waitUntilExit() on the main thread would stall with no SIGKILL fallback.

A bounded wait plus kill(pid, SIGKILL) escalation is the right hardening and I would rather add it with a test that actually exercises the escalation path than add an untested kill to a security fix. Noted on DEVA11Y-761 alongside the other deferred items.

Verified on the current head that the non-pathological path behaves: against the real 38 MB archive with a 5 MB cap the watchdog fires and bsdtar reports terminationStatus = 15 (SIGTERM), with disk bounded to 36 MB of the 66 MB it would otherwise have written.

/// Total bytes and entry count of all regular files under `url`.
func extractionFootprint(at url: URL) -> (bytes: Int64, entries: Int) {
let fm = FileManager.default
guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — two small issues in extractionFootprint.

  1. Fails open: if fm.enumerator(...) returns nil (directory transiently unreadable/missing), this returns (0, 0)footprintExceeded returns nil → "not exceeded" for that poll. Low risk since the plugin created the dir, but a transient failure silently disables the guard for that tick.
  2. Inconsistent "entry" definition: this enumerator omits .skipsHiddenFiles, while locateExecutable's enumerator (line ~646) passes options: [.skipsHiddenFiles]. The same 10 000 ceiling therefore counts hidden files here but not there. Align the two so "entries" means the same thing in both guards.

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.

Both fixed in 2c5fba8.

1. Fail-open → fail-closed. You are right that (0, 0) silently disabled the guard for that poll. It now fails closed:

guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [...]) else {
    // Fail CLOSED: a directory we just created being unreadable is not a "0 bytes"
    // result, and returning (0, 0) would silently disable the guard for that poll.
    return (Int64.max, Int.max)
}

A transient failure now trips the ceiling and aborts rather than waving the archive through. Failing closed is the right default for a guard, and the false-positive cost is an aborted download with a clear message.

2. Hidden-file inconsistency. Also real — but after looking at both call sites I kept the difference and documented it rather than aligning them, because they are measuring different things:

  • extractionFootprint measures what bsdtar actually wrote — dotfiles included, since they consume disk and count toward a "millions of tiny files" bomb. Adding .skipsHiddenFiles would let an all-dotfiles archive slip the entry ceiling.
  • locateExecutable is searching for a binary, so skipping hidden files is correct there.

So the shared 10_000 is deliberately counting different sets. Comment added at the enumerator making that explicit so the next reader does not "fix" it:

// `.skipsHiddenFiles` is deliberately NOT set, so the entry count here matches what
// bsdtar actually wrote — including dotfiles. locateExecutable skips hidden files
// because it is searching for a binary, not measuring a footprint; the two use the
// same ceiling but count deliberately different things (DEVA11Y-484 review).

Happy to split into two named constants if you would rather the shared 10_000 not imply the two are equivalent.

// verified first, then extracted. Windows' unzip path has no streaming guard.

/// Thread-safe flag shared between the extraction watchdog and the main flow.
final class ExtractionLimitState {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 — hygiene: these new top-level declarations lack private. ExtractionLimitState, extractionFootprint, footprintExceeded, and startExtractionWatchdog are the only non-private helpers in the file — every other helper (isTruthy, packageCacheRoot, hardwareIdentifier, …) is private. Harmless in a single-file plugin target, but worth marking private for consistency.

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.

Fixed in 2c5fba8 — all four are now private:

  • private final class ExtractionLimitState
  • private func extractionFootprint(at:)
  • private func footprintExceeded(at:maxBytes:maxEntries:)
  • private func startExtractionWatchdog(on:directory:maxBytes:maxEntries:)

Agreed it was inconsistent with every other helper in the file. swiftc -typecheck -parse-as-library is clean after the change.

//
// Applies to extractLocalArchive, which since #37 (DEVA11Y-473/474) is the single
// non-Windows extraction path: the archive is downloaded to a file and checksum-
// verified first, then extracted. Windows' unzip path has no streaming guard.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P2 — Windows extraction path is unguarded. This correctly notes the unzip/Expand-Archive path has no streaming guard, but Windows is a supported target (#if os(Windows) branches, browserstack-cli.exe, PowerShell checksum). A zip bomb there fully exhausts disk with no download cap, no watchdog, and no entry ceiling. It's out of this PR's stated 4-surface scope, so either add a guard to the Windows path or track it as an explicit follow-up so the gap is owned rather than just commented.

Also, defense-in-depth note for the non-Windows path: containment depends on libarchive's default behavior (bsdtar -x without -P neutralizes .., absolute paths, and symlink-through, keeping all writes inside the polled -C directory). That's correct today but load-bearing and unasserted — a future -P would let writes escape the polled dir and the footprint poll would measure nothing. Worth a comment pinning the assumption.

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.

Two parts here.

Windows — now tracked, not merely commented. Agreed it is a real gap, and it is explicitly owned: DEVA11Y-761 item 3, with the implementation preserved on chore/DEVA11Y-484-followup-extraction-guard-harness. That branch carries the prepareArtifact-level footprintExceeded backstop positioned against stagingDirectory before publishVersionDirectory — which is where it belongs after #32 restructured extraction, so a rejected archive never becomes a visible version directory.

It came out of this PR when the PR was narrowed to DEVA11Y-484's stated Remediation, which scopes the bsdtar paths only. I noted on the ticket that "Windows has no bomb guard" probably deserves its own security ticket rather than sitting in a cleanup task — say the word and I will raise one.

One thing that does help Windows in the meantime: the compressed-download cap added in 2c5fba8 sits in the shared download(from:to:), so it applies on Windows too. It does not bound decompression, but it stops a multi-GB archive reaching Expand-Archive at all.

libarchive containment — pinned. Good catch that it was load-bearing and unasserted. Now stated in the guard block:

// Containment assumption (load-bearing): `bsdtar -x` WITHOUT `-P` neutralises `..`,
// absolute paths and symlink-through, so every write lands inside the `-C` directory we
// poll. Adding `-P` would let writes escape that directory and the footprint poll would
// measure nothing — do not add it (DEVA11Y-484 review).

…[DEVA11Y-484]

Addresses @Crash0v3rrid3's review. The two P1/P2 "described but absent" findings
were caused by a stale PR description (the harness and compressed cap were
descoped to DEVA11Y-761 without updating it); the description is corrected
separately. This commit lands the code changes.

P2 — compressed-download cap reinstated. The reviewer's threat-model argument is
right: without a wire cap, a multi-GB *compressed* payload from an
attacker-controlled URL exhausts disk before the checksum or the decompression
guard ever run, walking around the whole fix.

* Launchers: `curl --max-filesize 104857600`, plus an explicit post-download size
  check because curl documents --max-filesize as a no-op when the length is
  unknown (chunked). Verified against the live endpoint: with a 1 MB cap curl
  aborts non-zero with nothing written to disk; with the real 100 MB cap the
  38 MB archive passes.
* Plugin: `maxCompressedBytes = 100 MB`, checked against both
  `response.expectedContentLength` and the downloaded file's actual size, with
  the temp file removed on rejection.

  LIMITATION, stated in the code rather than papered over:
  URLSession.download(from:) has no byte-level hook, so these reject the archive
  *after* the transfer rather than aborting mid-stream. They stop an oversized
  archive being verified, extracted, published or executed, but do NOT bound peak
  temporary disk during the transfer. Doing that needs a
  URLSessionDownloadDelegate cancelling in didWriteData — deliberately left to
  DEVA11Y-761 rather than rewriting this shared download path inside a security
  fix I cannot exercise end-to-end without credentials. The launchers do abort
  during transfer.

P3 fixes:
* `private` on ExtractionLimitState, extractionFootprint, footprintExceeded and
  startExtractionWatchdog, matching every other helper in the file.
* Poll-interval doc drift: the docstring now states the actual 50 ms instead of
  reasoning about an unstated interval.
* extractionFootprint now fails CLOSED on a nil enumerator (Int64.max/Int.max)
  instead of (0, 0), which silently disabled the guard for that poll; and the
  deliberate `.skipsHiddenFiles` asymmetry with locateExecutable is documented
  rather than accidental.
* pipefail is saved and restored instead of cleared unconditionally.
* ${BINARY_PATH}.tmp is cleaned up if chmod/mv fails, not only on size rejection.
* Pinned the load-bearing libarchive containment assumption: `bsdtar -x` without
  `-P` keeps writes inside the polled -C directory; adding -P would let them
  escape and the footprint poll would measure nothing.

Not addressed here (left for review discussion): the launchers still have no
entry-count equivalent to the plugin's maxArchiveEntries — in `-O` mode a
millions-of-empty-entries archive streams ~0 bytes so `head -c` never fires. Real
gap, no cheap mechanism in `-O` mode.

Verification: 27/27 assertions across bash/zsh/fish against the live endpoint
(real download through #37's integrity check, .tmp cleanup, byte-identical
re-run, corrupt payload rejected, cached binary survives rejection); pipefail
save/restore verified in both directions; swiftc -typecheck clean; bash -n clean;
all six sidecars verify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maunilm

maunilm commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — this was a genuinely useful review, and one of the two blockers was my fault in a way worth naming explicitly.

Everything below is in 2c5fba8, plus a rewritten PR description. All 9 checks green.

Blocker 1 (P1) — test suite and CI "described but absent": you were right, and the cause was a stale description

The suite and workflow really were not there. The reason is not that they were forgotten — they were deliberately removed when this PR was narrowed to DEVA11Y-484's stated Remediation (the PR had grown to +1044/−7 across 18 files for an XS ticket, with only 216 lines of production code). I removed the code and failed to update the description, so it kept advertising a harness, a CI workflow and "53/53 assertions" that no longer existed. That is exactly the kind of claim a reviewer should not have to discover by diffing, and it wasted your time.

The description is now rewritten to match the shipped code, with an explicit Known gaps section that owns all five gaps rather than implying coverage that does not exist.

The suite itself is not lost: preserved verbatim on chore/DEVA11Y-484-followup-extraction-guard-harness and tracked as DEVA11Y-761 item 1, flagged there as the highest-value follow-up. Your point stands that a security-critical guard is merging without regression protection — that is a conscious, documented trade, not an oversight.

Blocker 2 (P2) — no compressed cap: half stale description, half a real hole. Fixed.

Same root cause for the wording — the cap had been descoped as "not in the ticket's Remediation" and the Summary still claimed it. But your threat-model argument is the substantive part and I think it is correct: without a wire cap, a multi-GB compressed payload exhausts disk before the checksum or the decompression guard ever run, which walks around the entire fix. Deferring it on a scoping technicality was the wrong call.

Reinstated in 2c5fba8:

  • Launchers: curl --max-filesize 104857600, plus an explicit post-download size check because curl documents the flag as a no-op when the length is unknown (chunked). Verified against the live endpoint: with a 1 MB cap curl aborts non-zero with 0 bytes written; with the real 100 MB cap the 38 MB archive passes. (Observed exit is 56 rather than 63 — with -L following the 302 to sdk-assets it aborts during receive, not pre-transfer. The comment states the observed behaviour, not the idealised one.)
  • Plugin: maxCompressedBytes = 100 MB, checked against both response.expectedContentLength and the downloaded file's actual size, temp file removed on rejection. Applies on Windows too, since it lives in the shared download(from:to:).

One limitation I want to state plainly rather than let the description imply otherwise: URLSession.download(from:) has no byte-level hook, so on the Swift path these checks reject the archive after the transfer. They stop an oversized archive being verified, extracted, published or executed, but they do not bound peak temporary disk during the transfer — which is precisely the scenario you described. Bounding that needs a URLSessionDownloadDelegate cancelling in didWriteData. I chose not to rewrite that shared download path inside a security fix I cannot exercise end-to-end without credentials; it is documented inline, in Known gaps item 3, and on DEVA11Y-761. The launchers do abort during transfer, so the primary installer path is covered.

Blocker 3 (P2) — Windows unguarded

Tracked as DEVA11Y-761 item 3 with the implementation preserved on the follow-up branch, per your "either guard it or track it as an explicit follow-up so the gap is owned". Detail in the inline thread. I also noted on the ticket that this probably warrants its own security ticket rather than living in a cleanup task — happy to raise one.

P3s

Fixed: private on all four new decls; poll-interval doc drift (docs corrected to the real 50 ms rather than slowing the poll); extractionFootprint now fails closed on a nil enumerator; the .skipsHiddenFiles asymmetry documented as deliberate with reasoning; pipefail saved/restored and verified both directions; .tmp cleaned up on chmod/mv failure; libarchive containment assumption pinned in the guard block.

Deferred with reasoning in-thread: the launcher entry-count gap (no cheap correct mechanism in -O mode — the right fix is extracting to a directory, which is too large a change to the wrapper model to smuggle in here) and SIGTERM escalation (which you flagged low-impact yourself).

Verification on this head

No automated suite ships, so this was verified directly against the live endpoint: 27/27 assertions across bash/zsh/fish — real download through #37's integrity check, .tmp cleanup, byte-identical re-run, corrupt payload rejected, and the previously-cached binary's sha256 unchanged after a rejected payload (confirming the cap on ${BINARY_PATH}.tmp preserves #37's anti-truncation protection rather than undoing it). Swift guard compiled standalone and driven against the real archive: real 200 MB cap does not flag; 5 MB cap SIGTERMs bsdtar mid-stream bounding disk to 36 MB of 66 MB; maxEntries=0 flags on entry count. swiftc -typecheck clean, bash -n clean, all six sidecars verify, self-update's own comparison matches.

Ready for another look when you have a moment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants