From 7e139ab34aa78236ee6cb43c813001d2f54b2510 Mon Sep 17 00:00:00 2001 From: maunilm Date: Fri, 29 May 2026 17:55:55 +0530 Subject: [PATCH] fix(security): cap bsdtar extraction size to prevent decompression bomb 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) --- .../BrowserStackAccessibilityLint.swift | 87 ++++++++++++++++++- scripts/bash/cli.sh | 23 ++++- scripts/fish/cli.sh | 23 ++++- scripts/zsh/cli.sh | 23 ++++- 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift index 53d8bfe..1e9ab36 100644 --- a/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift +++ b/Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift @@ -170,6 +170,12 @@ private struct BrowserStackCLIDownloader { private var fileManager: FileManager { .default } + // Decompression-bomb guards (DEVA11Y-484). The CLI binary is a few tens of MB; these + // ceilings leave generous headroom while bounding a malicious archive's footprint. + private static let maxCompressedBytes = 100 * 1024 * 1024 // 100 MB on the wire + private static let maxDecompressedBytes: Int64 = 200 * 1024 * 1024 // 200 MB on disk + private static let maxArchiveEntries = 10_000 + func ensureArtifact() async throws -> BrowserStackCLIArtifact { if let overrideURL { let info = try await resolveOverrideArtifact(from: overrideURL) @@ -249,7 +255,9 @@ private struct BrowserStackCLIDownloader { let curl = Process() curl.executableURL = URL(fileURLWithPath: "/usr/bin/env") - curl.arguments = ["curl", "-fsSL", url.absoluteString] + // --max-filesize caps the *compressed* download as a coarse first line of defense + // against a malicious endpoint streaming an unbounded body. + curl.arguments = ["curl", "-fsSL", "--max-filesize", String(Self.maxCompressedBytes), url.absoluteString] curl.standardOutput = pipe let curlError = Pipe() curl.standardError = curlError @@ -267,6 +275,11 @@ private struct BrowserStackCLIDownloader { throw PluginError("Unable to launch bsdtar: \(error.localizedDescription)") } + // bsdtar writes decompressed bytes straight to disk, so a cap on the curl→bsdtar + // pipe would only bound the *compressed* size — useless against a decompression + // bomb. Guard the *decompressed* footprint instead (DEVA11Y-484). + let limitState = enforceExtractionSizeLimit(on: bsdtar, extractingInto: directory) + do { try curl.run() } catch { @@ -279,6 +292,11 @@ private struct BrowserStackCLIDownloader { pipe.fileHandleForWriting.closeFile() bsdtar.waitUntilExit() + if limitState.exceeded { + try? fileManager.removeItem(at: directory) + forwardExit(code: 1, message: "BrowserStack CLI archive exceeds the maximum allowed decompressed size of \(Self.maxDecompressedBytes / (1024 * 1024)) MB. Aborting to prevent disk exhaustion.") + } + if curl.terminationStatus != 0 { let message = String(data: curlError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" forwardExit(code: curl.terminationStatus, message: message) @@ -290,6 +308,39 @@ private struct BrowserStackCLIDownloader { } } + /// Starts a background watchdog that terminates `bsdtar` if the decompressed footprint in + /// `directory` exceeds `maxDecompressedBytes`. Returns the shared state to inspect afterwards. + private func enforceExtractionSizeLimit(on bsdtar: Process, extractingInto directory: URL) -> ExtractionLimitState { + let state = ExtractionLimitState() + let watchdog = Thread { + while bsdtar.isRunning { + if Self.directorySize(at: directory) > Self.maxDecompressedBytes { + state.markExceeded() + bsdtar.terminate() + break + } + Thread.sleep(forTimeInterval: 0.2) + } + } + watchdog.start() + return state + } + + private static func directorySize(at url: URL) -> Int64 { + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else { + return 0 + } + var total: Int64 = 0 + for case let element as URL in enumerator { + let values = try? element.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + if values?.isRegularFile == true, let size = values?.fileSize { + total += Int64(size) + } + } + return total + } + private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -297,13 +348,21 @@ private struct BrowserStackCLIDownloader { let errorPipe = Pipe() process.standardError = errorPipe + let limitState: ExtractionLimitState do { try process.run() + // Decompressed-size guard (DEVA11Y-484): same rationale as the remote path. + limitState = enforceExtractionSizeLimit(on: process, extractingInto: directory) process.waitUntilExit() } catch { throw PluginError("Failed to launch bsdtar: \(error.localizedDescription)") } + if limitState.exceeded { + try? fileManager.removeItem(at: directory) + forwardExit(code: 1, message: "BrowserStack CLI archive exceeds the maximum allowed decompressed size of \(Self.maxDecompressedBytes / (1024 * 1024)) MB. Aborting to prevent disk exhaustion.") + } + if process.terminationReason != .exit || process.terminationStatus != 0 { // Fall back to copying the file directly if it's already an executable. let message = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -466,8 +525,16 @@ private struct BrowserStackCLIDownloader { ) var fallback: URL? + var scanned = 0 while let element = enumerator?.nextObject() as? URL { + scanned += 1 + if scanned > Self.maxArchiveEntries { + // Bound enumeration so an archive packed with millions of entries can't turn + // locateExecutable into a CPU/IO drain (DEVA11Y-484). + throw PluginError("Extracted archive contains more than \(Self.maxArchiveEntries) entries; refusing to continue.") + } + var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: element.path, isDirectory: &isDirectory), !isDirectory.boolValue else { continue @@ -603,6 +670,24 @@ private func isAlpineLinux() -> Bool { false } // MARK: - Error +/// Thread-safe flag shared between the extraction watchdog and the main flow. +private final class ExtractionLimitState { + private let lock = NSLock() + private var didExceed = false + + func markExceeded() { + lock.lock() + didExceed = true + lock.unlock() + } + + var exceeded: Bool { + lock.lock() + defer { lock.unlock() } + return didExceed + } +} + private struct PluginError: Error, CustomStringConvertible { let message: String diff --git a/scripts/bash/cli.sh b/scripts/bash/cli.sh index 818d993..080e8be 100644 --- a/scripts/bash/cli.sh +++ b/scripts/bash/cli.sh @@ -88,8 +88,27 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" + local max_compressed=104857600 # 100 MB cap on the compressed download + local max_decompressed=209715200 # 200 MB cap on the decompressed binary + + curl --max-filesize "$max_compressed" -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + + # Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE) + # once the decompressed output reaches the cap; pipefail surfaces that as a failure. + set -o pipefail + bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH" + local extract_status=$? + set +o pipefail + + local extracted_size + extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0) + if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then + echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2 + rm -f "$BINARY_PATH" + exit 1 + fi + + chmod 0775 "$BINARY_PATH" } script_self_update diff --git a/scripts/fish/cli.sh b/scripts/fish/cli.sh index e509be7..0f18c82 100644 --- a/scripts/fish/cli.sh +++ b/scripts/fish/cli.sh @@ -100,8 +100,27 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" + local max_compressed=104857600 # 100 MB cap on the compressed download + local max_decompressed=209715200 # 200 MB cap on the decompressed binary + + curl --max-filesize "$max_compressed" -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + + # Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE) + # once the decompressed output reaches the cap; pipefail surfaces that as a failure. + set -o pipefail + bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH" + local extract_status=$? + set +o pipefail + + local extracted_size + extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0) + if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then + echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2 + rm -f "$BINARY_PATH" + exit 1 + fi + + chmod 0775 "$BINARY_PATH" } script_self_update diff --git a/scripts/zsh/cli.sh b/scripts/zsh/cli.sh index a7e6e4c..5d346b9 100644 --- a/scripts/zsh/cli.sh +++ b/scripts/zsh/cli.sh @@ -99,8 +99,27 @@ script_self_update() { } download_binary() { - curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" - bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH" + local max_compressed=104857600 # 100 MB cap on the compressed download + local max_decompressed=209715200 # 200 MB cap on the decompressed binary + + curl --max-filesize "$max_compressed" -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH" + + # Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE) + # once the decompressed output reaches the cap; pipefail surfaces that as a failure. + set -o pipefail + bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH" + local extract_status=$? + set +o pipefail + + local extracted_size + extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0) + if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then + echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2 + rm -f "$BINARY_PATH" + exit 1 + fi + + chmod 0775 "$BINARY_PATH" } script_self_update