fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484] - #25
fix(security): cap bsdtar extraction size to prevent decompression bomb DoS [DEVA11Y-484]#25maunilm wants to merge 9 commits into
Conversation
…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>
…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>
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
left a comment
There was a problem hiding this comment.
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()→removeItemordering is safe,ExtractionLimitState'sNSLockis correct, andforwardExitis-> Never/exit(no fall-through). - Shell logic is correct: the bomb is genuinely caught (
head -ccap → SIGPIPE → 141 viapipefail, with-geas a backstop), legit downloads pass, andlocal x=$?captures the pipeline status correctly. scripts/fish/cli.shis#!/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 -xwithout-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 downloadcurl(curl -fR -z … -L … -o …) has no--max-filesize.BrowserStackAccessibilityLint.swift—download(...)usesURLSession.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.
| # 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" |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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 -tfpre-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-timeon 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
-Oso 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 frommain. 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.
| set -o pipefail | ||
| bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "${BINARY_PATH}.tmp" | ||
| local extract_status=$? | ||
| set +o pipefail |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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 pipefailVerified 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
P3 — two small issues in extractionFootprint.
- Fails open: if
fm.enumerator(...)returnsnil(directory transiently unreadable/missing), this returns(0, 0)→footprintExceededreturnsnil→ "not exceeded" for that poll. Low risk since the plugin created the dir, but a transient failure silently disables the guard for that tick. - Inconsistent "entry" definition: this enumerator omits
.skipsHiddenFiles, whilelocateExecutable's enumerator (line ~646) passesoptions: [.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.
There was a problem hiding this comment.
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:
extractionFootprintmeasures what bsdtar actually wrote — dotfiles included, since they consume disk and count toward a "millions of tiny files" bomb. Adding.skipsHiddenFileswould let an all-dotfiles archive slip the entry ceiling.locateExecutableis 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 2c5fba8 — all four are now private:
private final class ExtractionLimitStateprivate 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
|
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 descriptionThe 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 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:
One limitation I want to state plainly rather than let the description imply otherwise: Blocker 3 (P2) — Windows unguardedTracked 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. P3sFixed: Deferred with reasoning in-thread: the launcher entry-count gap (no cheap correct mechanism in Verification on this headNo 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, Ready for another look when you have a moment. |
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-Archivebackstop — was removed and is tracked in DEVA11Y-761, preserved on branchchore/DEVA11Y-484-followup-extraction-guard-harness. See Known gaps below.Changes
Swift plugin —
Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swiftstartExtractionWatchdogpolls the extraction directory every 50 ms while bsdtar runs andterminate()s it once the decompressed footprint crossesmaxDecompressedBytes(200 MB) ormaxArchiveEntries(10,000). A soft ceiling by design: peak disk ≈maxBytes + (50 ms × write rate).footprintExceededre-check catches a bomb that finishes inside one poll interval.locateExecutablethrows past 10,000 entries, per the ticket's ask.maxCompressedBytes(100 MB) checked againstresponse.expectedContentLengthand 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 streamingcurl | bsdtarpath that #37 deleted is gone, so there is no separate remote guard.Launchers —
scripts/{bash,zsh,fish}/cli.shbsdtar … -O | head -c 209715200withpipefail, so the cap is enforced by SIGPIPE, plus an explicit size assertion as a backstop.curl --max-filesize 104857600plus a post-download size check (curl documents the flag as a no-op when the length is unknown).${BINARY_PATH}.tmpand publication stays a latermv, so a rejected payload cannot truncate a previously-good cached binary — preserving fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's protection.download_binary || exit $?, so fix(cli): verify downloaded CLI binary integrity before exec (DEVA11Y-473/474) #37's distinctexit 2for an integrity mismatch survives.Verification
No automated suite ships with this PR (see Known gaps), so this was verified directly against the live download endpoint:
.tmpcleaned up after publish; re-run byte-identical; corrupt payload rejected; and the previously-cached binary's sha256 is unchanged after a rejected payload.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=0flags on entry count.swiftc -typecheck -parse-as-libraryclean;bash -nclean on all three launchers; all six.sha256sidecars 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
Expand-Archiveis unguarded. No download cap enforcement mid-stream, no watchdog, no entry ceiling on that branch. Unchanged frommain, but a real gap. DEVA11Y-761 item 3.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 aURLSessionDownloadDelegatecancelling indidWriteData. The launchers do abort during transfer. DEVA11Y-761.maxArchiveEntries. In-Omode an archive of millions of empty entries streams ~0 bytes, sohead -cnever fires; disk stays bounded but bsdtar still parses every entry. Open for review discussion — no cheap mechanism in-Omode.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 tohttps://sdk-assets.browserstack.com, which weakens the stated MitM reachability behind theAV:N/ CVSS 5.3 rating. TheBROWSERSTACK_A11Y_CLI_DOWNLOAD_URLoverride remains a genuine vector, so the fix stands — but the premise as written is inaccurate.Refs DEVA11Y-484, DEVA11Y-761, APPSEC-415.