@@ -100,10 +100,13 @@ private func parseOverride(urlString: String?) throws -> URL? {
100100 guard let urlString = urlString, !urlString. isEmpty else {
101101 return nil
102102 }
103- if let url = URL ( string: urlString) , let scheme = url. scheme, [ " http " , " https " , " file " ] . contains ( scheme. lowercased ( ) ) {
104- return url
103+ guard let url = URL ( string: urlString) , let scheme = url. scheme else {
104+ throw PluginError ( " Invalid download URL: \( urlString) . Only HTTPS URLs are supported. " )
105+ }
106+ guard scheme. lowercased ( ) == " https " else {
107+ throw PluginError ( " Unsupported URL scheme ' \( scheme) ' in download URL. Only HTTPS is allowed. " )
105108 }
106- return URL ( fileURLWithPath : urlString )
109+ return url
107110}
108111
109112private func sanitizeArguments( _ arguments: [ String ] ) -> [ String ] {
@@ -199,33 +202,40 @@ private struct BrowserStackCLIDownloader {
199202 return BrowserStackCLIArtifact ( version: info. version, executableURL: expectedExecutableURL)
200203 }
201204
202- if fileManager. fileExists ( atPath: versionDirectory. path) {
203- try fileManager. removeItem ( at: versionDirectory)
204- }
205- try fileManager. createDirectory ( at: versionDirectory, withIntermediateDirectories: true )
206-
207205 Diagnostics . remark ( " BrowserStackAccessibilityLint: Downloading CLI \( info. version) ... " )
208206
207+ // Download into a temporary directory to avoid TOCTOU races
208+ let tempDirectory = cacheRoot. appendingPathComponent ( " .download- \( UUID ( ) . uuidString) " , isDirectory: true )
209+ try fileManager. createDirectory ( at: tempDirectory, withIntermediateDirectories: true )
210+ defer { try ? fileManager. removeItem ( at: tempDirectory) }
211+
209212 #if os(Windows)
210- let archiveURL = versionDirectory . appendingPathComponent ( " browserstack-cli.zip " )
213+ let archiveURL = tempDirectory . appendingPathComponent ( " browserstack-cli.zip " )
211214 try await download ( from: info. resolvedURL, to: archiveURL)
212215 Diagnostics . remark ( " BrowserStackAccessibilityLint: Extracting CLI \( info. version) ... " )
213- try unzip ( archive: archiveURL, into: versionDirectory )
216+ try unzip ( archive: archiveURL, into: tempDirectory )
214217 try ? fileManager. removeItem ( at: archiveURL)
215218 #else
216- try extractWithBsdtar ( from: info. resolvedURL, into: versionDirectory )
219+ try extractWithBsdtar ( from: info. resolvedURL, into: tempDirectory )
217220 #endif
218221
219- let locatedBinary = try locateExecutable ( in: versionDirectory, preferredName: executableName)
220- let finalBinaryURL : URL
221- if locatedBinary. lastPathComponent == executableName {
222- finalBinaryURL = locatedBinary
223- } else {
224- finalBinaryURL = expectedExecutableURL
225- if fileManager. fileExists ( atPath: finalBinaryURL. path) {
226- try fileManager. removeItem ( at: finalBinaryURL)
222+ let locatedBinary = try locateExecutable ( in: tempDirectory, preferredName: executableName)
223+
224+ // Atomically swap: remove old version dir, move temp into place
225+ if fileManager. fileExists ( atPath: versionDirectory. path) {
226+ try fileManager. removeItem ( at: versionDirectory)
227+ }
228+ try fileManager. moveItem ( at: tempDirectory, to: versionDirectory)
229+
230+ let finalBinaryURL = versionDirectory. appendingPathComponent ( locatedBinary. lastPathComponent, isDirectory: false )
231+ if locatedBinary. lastPathComponent != executableName {
232+ let expectedURL = versionDirectory. appendingPathComponent ( executableName, isDirectory: false )
233+ if fileManager. fileExists ( atPath: expectedURL. path) {
234+ try fileManager. removeItem ( at: expectedURL)
227235 }
228- try fileManager. moveItem ( at: locatedBinary, to: finalBinaryURL)
236+ try fileManager. moveItem ( at: finalBinaryURL, to: expectedURL)
237+ try ensureExecutablePermissions ( at: expectedURL)
238+ return BrowserStackCLIArtifact ( version: info. version, executableURL: expectedURL)
229239 }
230240
231241 try ensureExecutablePermissions ( at: finalBinaryURL)
@@ -285,6 +295,8 @@ private struct BrowserStackCLIDownloader {
285295 let message = String ( data: tarError. fileHandleForReading. readDataToEndOfFile ( ) , encoding: . utf8) ? . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
286296 forwardExit ( code: bsdtar. terminationStatus, message: message. isEmpty ? " bsdtar failed to extract BrowserStack CLI. " : message)
287297 }
298+
299+ try validateExtractedSize ( of: directory)
288300 }
289301
290302 private func extractLocalArchive( at archiveURL: URL , into directory: URL ) throws {
@@ -301,6 +313,11 @@ private struct BrowserStackCLIDownloader {
301313 throw PluginError ( " Failed to launch bsdtar: \( error. localizedDescription) " )
302314 }
303315
316+ if process. terminationReason == . exit && process. terminationStatus == 0 {
317+ try validateExtractedSize ( of: directory)
318+ return
319+ }
320+
304321 if process. terminationReason != . exit || process. terminationStatus != 0 {
305322 // Fall back to copying the file directly if it's already an executable.
306323 let message = String ( data: errorPipe. fileHandleForReading. readDataToEndOfFile ( ) , encoding: . utf8) ? . trimmingCharacters ( in: . whitespacesAndNewlines) ?? " "
@@ -315,6 +332,24 @@ private struct BrowserStackCLIDownloader {
315332 }
316333 }
317334 }
335+
336+ private func validateExtractedSize( of directory: URL , maxBytes: UInt64 = 100 * 1024 * 1024 ) throws {
337+ var totalSize : UInt64 = 0
338+ let enumerator = fileManager. enumerator (
339+ at: directory,
340+ includingPropertiesForKeys: [ . fileSizeKey] ,
341+ options: [ . skipsHiddenFiles]
342+ )
343+ while let fileURL = enumerator? . nextObject ( ) as? URL {
344+ if let size = try ? fileURL. resourceValues ( forKeys: [ . fileSizeKey] ) . fileSize {
345+ totalSize += UInt64 ( size)
346+ if totalSize > maxBytes {
347+ try ? fileManager. removeItem ( at: directory)
348+ throw PluginError ( " Extracted archive exceeds maximum allowed size ( \( maxBytes / ( 1024 * 1024 ) ) MB). Possible decompression bomb. " )
349+ }
350+ }
351+ }
352+ }
318353 #endif
319354
320355 private func resolveOverrideArtifact( from url: URL ) async throws -> ArtifactInfo {
@@ -557,8 +592,13 @@ private func hardwareIdentifier() throws -> String {
557592private func extractVersion( from url: URL ) -> String ? {
558593 let filename = url. deletingPathExtension ( ) . lastPathComponent
559594 if let range = filename. range ( of: " - " , options: . backwards) {
560- let version = filename [ range. upperBound... ]
561- return version. isEmpty ? nil : String ( version)
595+ let version = String ( filename [ range. upperBound... ] )
596+ if version. isEmpty { return nil }
597+ // Reject path traversal and non-semver characters
598+ let allowed = CharacterSet . alphanumerics. union ( CharacterSet ( charactersIn: " .-+ " ) )
599+ guard version. unicodeScalars. allSatisfy ( { allowed. contains ( $0) } ) else { return nil }
600+ guard !version. contains ( " .. " ) else { return nil }
601+ return version
562602 }
563603 return nil
564604}
0 commit comments