Skip to content

Commit 7e139ab

Browse files
maunilmclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 576f0d5 commit 7e139ab

4 files changed

Lines changed: 149 additions & 7 deletions

File tree

Plugins/BrowserStackAccessibilityLint/BrowserStackAccessibilityLint.swift

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,12 @@ private struct BrowserStackCLIDownloader {
170170

171171
private var fileManager: FileManager { .default }
172172

173+
// Decompression-bomb guards (DEVA11Y-484). The CLI binary is a few tens of MB; these
174+
// ceilings leave generous headroom while bounding a malicious archive's footprint.
175+
private static let maxCompressedBytes = 100 * 1024 * 1024 // 100 MB on the wire
176+
private static let maxDecompressedBytes: Int64 = 200 * 1024 * 1024 // 200 MB on disk
177+
private static let maxArchiveEntries = 10_000
178+
173179
func ensureArtifact() async throws -> BrowserStackCLIArtifact {
174180
if let overrideURL {
175181
let info = try await resolveOverrideArtifact(from: overrideURL)
@@ -249,7 +255,9 @@ private struct BrowserStackCLIDownloader {
249255

250256
let curl = Process()
251257
curl.executableURL = URL(fileURLWithPath: "/usr/bin/env")
252-
curl.arguments = ["curl", "-fsSL", url.absoluteString]
258+
// --max-filesize caps the *compressed* download as a coarse first line of defense
259+
// against a malicious endpoint streaming an unbounded body.
260+
curl.arguments = ["curl", "-fsSL", "--max-filesize", String(Self.maxCompressedBytes), url.absoluteString]
253261
curl.standardOutput = pipe
254262
let curlError = Pipe()
255263
curl.standardError = curlError
@@ -267,6 +275,11 @@ private struct BrowserStackCLIDownloader {
267275
throw PluginError("Unable to launch bsdtar: \(error.localizedDescription)")
268276
}
269277

278+
// bsdtar writes decompressed bytes straight to disk, so a cap on the curl→bsdtar
279+
// pipe would only bound the *compressed* size — useless against a decompression
280+
// bomb. Guard the *decompressed* footprint instead (DEVA11Y-484).
281+
let limitState = enforceExtractionSizeLimit(on: bsdtar, extractingInto: directory)
282+
270283
do {
271284
try curl.run()
272285
} catch {
@@ -279,6 +292,11 @@ private struct BrowserStackCLIDownloader {
279292
pipe.fileHandleForWriting.closeFile()
280293
bsdtar.waitUntilExit()
281294

295+
if limitState.exceeded {
296+
try? fileManager.removeItem(at: directory)
297+
forwardExit(code: 1, message: "BrowserStack CLI archive exceeds the maximum allowed decompressed size of \(Self.maxDecompressedBytes / (1024 * 1024)) MB. Aborting to prevent disk exhaustion.")
298+
}
299+
282300
if curl.terminationStatus != 0 {
283301
let message = String(data: curlError.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
284302
forwardExit(code: curl.terminationStatus, message: message)
@@ -290,20 +308,61 @@ private struct BrowserStackCLIDownloader {
290308
}
291309
}
292310

311+
/// Starts a background watchdog that terminates `bsdtar` if the decompressed footprint in
312+
/// `directory` exceeds `maxDecompressedBytes`. Returns the shared state to inspect afterwards.
313+
private func enforceExtractionSizeLimit(on bsdtar: Process, extractingInto directory: URL) -> ExtractionLimitState {
314+
let state = ExtractionLimitState()
315+
let watchdog = Thread {
316+
while bsdtar.isRunning {
317+
if Self.directorySize(at: directory) > Self.maxDecompressedBytes {
318+
state.markExceeded()
319+
bsdtar.terminate()
320+
break
321+
}
322+
Thread.sleep(forTimeInterval: 0.2)
323+
}
324+
}
325+
watchdog.start()
326+
return state
327+
}
328+
329+
private static func directorySize(at url: URL) -> Int64 {
330+
let fm = FileManager.default
331+
guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else {
332+
return 0
333+
}
334+
var total: Int64 = 0
335+
for case let element as URL in enumerator {
336+
let values = try? element.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
337+
if values?.isRegularFile == true, let size = values?.fileSize {
338+
total += Int64(size)
339+
}
340+
}
341+
return total
342+
}
343+
293344
private func extractLocalArchive(at archiveURL: URL, into directory: URL) throws {
294345
let process = Process()
295346
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
296347
process.arguments = ["bsdtar", "-xpf", archiveURL.path, "-C", directory.path]
297348
let errorPipe = Pipe()
298349
process.standardError = errorPipe
299350

351+
let limitState: ExtractionLimitState
300352
do {
301353
try process.run()
354+
// Decompressed-size guard (DEVA11Y-484): same rationale as the remote path.
355+
limitState = enforceExtractionSizeLimit(on: process, extractingInto: directory)
302356
process.waitUntilExit()
303357
} catch {
304358
throw PluginError("Failed to launch bsdtar: \(error.localizedDescription)")
305359
}
306360

361+
if limitState.exceeded {
362+
try? fileManager.removeItem(at: directory)
363+
forwardExit(code: 1, message: "BrowserStack CLI archive exceeds the maximum allowed decompressed size of \(Self.maxDecompressedBytes / (1024 * 1024)) MB. Aborting to prevent disk exhaustion.")
364+
}
365+
307366
if process.terminationReason != .exit || process.terminationStatus != 0 {
308367
// Fall back to copying the file directly if it's already an executable.
309368
let message = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -466,8 +525,16 @@ private struct BrowserStackCLIDownloader {
466525
)
467526

468527
var fallback: URL?
528+
var scanned = 0
469529

470530
while let element = enumerator?.nextObject() as? URL {
531+
scanned += 1
532+
if scanned > Self.maxArchiveEntries {
533+
// Bound enumeration so an archive packed with millions of entries can't turn
534+
// locateExecutable into a CPU/IO drain (DEVA11Y-484).
535+
throw PluginError("Extracted archive contains more than \(Self.maxArchiveEntries) entries; refusing to continue.")
536+
}
537+
471538
var isDirectory: ObjCBool = false
472539
guard fileManager.fileExists(atPath: element.path, isDirectory: &isDirectory), !isDirectory.boolValue else {
473540
continue
@@ -603,6 +670,24 @@ private func isAlpineLinux() -> Bool { false }
603670

604671
// MARK: - Error
605672

673+
/// Thread-safe flag shared between the extraction watchdog and the main flow.
674+
private final class ExtractionLimitState {
675+
private let lock = NSLock()
676+
private var didExceed = false
677+
678+
func markExceeded() {
679+
lock.lock()
680+
didExceed = true
681+
lock.unlock()
682+
}
683+
684+
var exceeded: Bool {
685+
lock.lock()
686+
defer { lock.unlock() }
687+
return didExceed
688+
}
689+
}
690+
606691
private struct PluginError: Error, CustomStringConvertible {
607692
let message: String
608693

scripts/bash/cli.sh

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,27 @@ script_self_update() {
8888
}
8989

9090
download_binary() {
91-
curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
92-
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH"
91+
local max_compressed=104857600 # 100 MB cap on the compressed download
92+
local max_decompressed=209715200 # 200 MB cap on the decompressed binary
93+
94+
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"
95+
96+
# Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE)
97+
# once the decompressed output reaches the cap; pipefail surfaces that as a failure.
98+
set -o pipefail
99+
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH"
100+
local extract_status=$?
101+
set +o pipefail
102+
103+
local extracted_size
104+
extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0)
105+
if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then
106+
echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2
107+
rm -f "$BINARY_PATH"
108+
exit 1
109+
fi
110+
111+
chmod 0775 "$BINARY_PATH"
93112
}
94113

95114
script_self_update

scripts/fish/cli.sh

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,27 @@ script_self_update() {
100100
}
101101

102102
download_binary() {
103-
curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
104-
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH"
103+
local max_compressed=104857600 # 100 MB cap on the compressed download
104+
local max_decompressed=209715200 # 200 MB cap on the decompressed binary
105+
106+
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"
107+
108+
# Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE)
109+
# once the decompressed output reaches the cap; pipefail surfaces that as a failure.
110+
set -o pipefail
111+
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH"
112+
local extract_status=$?
113+
set +o pipefail
114+
115+
local extracted_size
116+
extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0)
117+
if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then
118+
echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2
119+
rm -f "$BINARY_PATH"
120+
exit 1
121+
fi
122+
123+
chmod 0775 "$BINARY_PATH"
105124
}
106125

107126
script_self_update

scripts/zsh/cli.sh

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,27 @@ script_self_update() {
9999
}
100100

101101
download_binary() {
102-
curl -R -z "$BINARY_ZIP_PATH" -L "https://api.browserstack.com/sdk/v1/download_cli?os=${OS}&os_arch=${ARCH}" -o "$BINARY_ZIP_PATH"
103-
bsdtar -xvf "$BINARY_ZIP_PATH" -O > "$BINARY_PATH" && chmod 0775 "$BINARY_PATH"
102+
local max_compressed=104857600 # 100 MB cap on the compressed download
103+
local max_decompressed=209715200 # 200 MB cap on the decompressed binary
104+
105+
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"
106+
107+
# Guard against a decompression bomb (DEVA11Y-484): head -c stops bsdtar (via SIGPIPE)
108+
# once the decompressed output reaches the cap; pipefail surfaces that as a failure.
109+
set -o pipefail
110+
bsdtar -xvf "$BINARY_ZIP_PATH" -O | head -c "$max_decompressed" > "$BINARY_PATH"
111+
local extract_status=$?
112+
set +o pipefail
113+
114+
local extracted_size
115+
extracted_size=$(wc -c < "$BINARY_PATH" 2>/dev/null || echo 0)
116+
if [[ $extract_status -ne 0 || $extracted_size -ge $max_decompressed ]]; then
117+
echo "BrowserStack CLI download failed or exceeds the maximum allowed size (200 MB). Aborting." >&2
118+
rm -f "$BINARY_PATH"
119+
exit 1
120+
fi
121+
122+
chmod 0775 "$BINARY_PATH"
104123
}
105124

106125
script_self_update

0 commit comments

Comments
 (0)