Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -290,20 +308,61 @@ 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")
process.arguments = ["bsdtar", "-xpf", archiveURL.path, "-C", directory.path]
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) ?? ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
23 changes: 21 additions & 2 deletions scripts/bash/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions scripts/fish/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions scripts/zsh/cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading