@capgo/capacitor-updater 8.51.21 → 8.51.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -134,6 +134,10 @@ import UIKit
134
134
  case pathTraversal
135
135
  }
136
136
 
137
+ static func containsPathTraversalSegment(_ relativePath: String) -> Bool {
138
+ return relativePath.split(separator: "/").contains(where: { $0 == ".." })
139
+ }
140
+
137
141
  static func resolvePathInsideDirectory(baseDirectory: URL, relativePath: String) throws -> URL {
138
142
  if relativePath.isEmpty {
139
143
  throw SecurePathError.emptyPath
@@ -141,6 +145,9 @@ import UIKit
141
145
  if relativePath.contains("\\") || relativePath.contains("\0") {
142
146
  throw SecurePathError.windowsPath
143
147
  }
148
+ if containsPathTraversalSegment(relativePath) {
149
+ throw SecurePathError.pathTraversal
150
+ }
144
151
  if (relativePath as NSString).isAbsolutePath {
145
152
  throw SecurePathError.absolutePath
146
153
  }
@@ -151,19 +158,41 @@ import UIKit
151
158
  let canonicalTarget = canonicalBase.appendingPathComponent(relativePath).standardizedFileURL
152
159
  let canonicalTargetPath = canonicalTarget.path
153
160
 
154
- if canonicalTargetPath != canonicalBasePath && !canonicalTargetPath.hasPrefix(normalizedBasePath) {
161
+ // Require a strict child of the base. Equality would accept "." and wipe/write the root.
162
+ if !canonicalTargetPath.hasPrefix(normalizedBasePath) {
155
163
  throw SecurePathError.pathTraversal
156
164
  }
157
165
 
158
166
  return canonicalTarget
159
167
  }
160
168
 
169
+ static func resolveBundleDirectory(libraryDir: URL, bundleId: String) throws -> URL {
170
+ let bundleRoot = libraryDir.appendingPathComponent("NoCloud/ionic_built_snapshots")
171
+ return try resolvePathInsideDirectory(baseDirectory: bundleRoot, relativePath: bundleId)
172
+ }
173
+
161
174
  static func resolveManifestTargetPath(baseDirectory: URL, fileName: String) throws -> URL {
162
175
  let isBrotli = fileName.hasSuffix(".br")
163
176
  let targetFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
164
177
  return try resolvePathInsideDirectory(baseDirectory: baseDirectory, relativePath: targetFileName)
165
178
  }
166
179
 
180
+ static func rememberManifestTarget(_ seenTargets: inout Set<String>, targetFile: URL) -> Bool {
181
+ return seenTargets.insert(targetFile.standardizedFileURL.path).inserted
182
+ }
183
+
184
+ private struct ManifestDownloadTask {
185
+ let fileName: String
186
+ let downloadUrl: String
187
+ let finalFileHash: String
188
+ let isBrotli: Bool
189
+ let destFileName: String
190
+ let destFilePath: URL
191
+ let builtinFilePath: URL
192
+ let cacheFilePath: URL?
193
+ let legacyCacheFilePath: URL?
194
+ }
195
+
167
196
  private func isTimedOutError(_ error: Error?) -> Bool {
168
197
  guard let nsError = error as NSError? else {
169
198
  return false
@@ -432,6 +461,18 @@ import UIKit
432
461
  return String((0..<length).map { _ in letters.randomElement()! })
433
462
  }
434
463
 
464
+ private func requireSessionKeyForEncryptedUpdate(sessionKey: String, versionName: String? = nil) throws {
465
+ if !self.publicKey.isEmpty && !CryptoCipher.isValidSessionKey(sessionKey) {
466
+ logger.error("Public key present but no valid session key provided")
467
+ self.sendStats(action: "session_key_required", versionName: versionName)
468
+ throw NSError(
469
+ domain: "CapgoUpdater",
470
+ code: 1,
471
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
472
+ )
473
+ }
474
+ }
475
+
435
476
  public func setPublicKey(_ publicKey: String) {
436
477
  // Empty string means no encryption - proceed normally
437
478
  if publicKey.isEmpty {
@@ -963,7 +1004,13 @@ import UIKit
963
1004
  /// `manifest` must only contain entries the caller already checksum-verified
964
1005
  /// (as `downloadManifest` does) — the hashes are trusted as-is, not re-checked.
965
1006
  func populateDeltaCache(for id: String, manifest: [ManifestEntry]? = nil, sessionKey: String = "") {
966
- let bundleDir = self.getBundleDirectory(id: id)
1007
+ let bundleDir: URL
1008
+ do {
1009
+ bundleDir = try self.getBundleDirectory(id: id)
1010
+ } catch {
1011
+ logger.debug("Skip delta cache population: invalid bundle id")
1012
+ return
1013
+ }
967
1014
  let fileManager = FileManager.default
968
1015
 
969
1016
  guard fileManager.fileExists(atPath: bundleDir.path) else {
@@ -1223,7 +1270,10 @@ import UIKit
1223
1270
  guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
1224
1271
  return nil
1225
1272
  }
1226
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1273
+ if !self.publicKey.isEmpty {
1274
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1275
+ return nil
1276
+ }
1227
1277
  do {
1228
1278
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1229
1279
  } catch {
@@ -1384,10 +1434,11 @@ import UIKit
1384
1434
  }
1385
1435
 
1386
1436
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
1437
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
1387
1438
  try self.runBeforeDownload()
1388
1439
  let id = self.randomString(length: 10)
1389
1440
  logger.info("downloadManifest start \(id)")
1390
- let destFolder = self.getBundleDirectory(id: id)
1441
+ let destFolder = try self.getBundleDirectory(id: id)
1391
1442
  let builtinFolder = self.builtinFolderURL()
1392
1443
 
1393
1444
  // Check disk space before starting manifest download (estimate 100KB per file, minimum 50MB)
@@ -1416,8 +1467,8 @@ import UIKit
1416
1467
  var downloadError: Error?
1417
1468
  let errorLock = NSLock()
1418
1469
 
1419
- // Create operations for each file
1420
- var operations: [Operation] = []
1470
+ var tasks: [ManifestDownloadTask] = []
1471
+ var seenTargets = Set<String>()
1421
1472
 
1422
1473
  for entry in manifest {
1423
1474
  guard let fileName = entry.file_name,
@@ -1458,7 +1509,23 @@ import UIKit
1458
1509
  var fileHash = entryFileHash
1459
1510
 
1460
1511
  // Decrypt checksum if needed (done before creating operation)
1461
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1512
+ if !self.publicKey.isEmpty {
1513
+ if !CryptoCipher.isValidSessionKey(sessionKey) {
1514
+ let error = NSError(
1515
+ domain: "CapgoUpdater",
1516
+ code: 1,
1517
+ userInfo: [NSLocalizedDescriptionKey: "Session key required when public key is present"]
1518
+ )
1519
+ self.sendStats(action: "session_key_required", versionName: version)
1520
+ errorLock.lock()
1521
+ if downloadError == nil {
1522
+ downloadError = error
1523
+ }
1524
+ errorLock.unlock()
1525
+ hasError.value = true
1526
+ logger.error("Public key present but no valid session key provided")
1527
+ continue
1528
+ }
1462
1529
  do {
1463
1530
  fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
1464
1531
  } catch {
@@ -1489,6 +1556,24 @@ import UIKit
1489
1556
  do {
1490
1557
  destFilePath = try Self.resolveManifestTargetPath(baseDirectory: destFolder, fileName: fileName)
1491
1558
  builtinFilePath = try Self.resolveManifestTargetPath(baseDirectory: builtinFolder, fileName: fileName)
1559
+ if !Self.rememberManifestTarget(&seenTargets, targetFile: destFilePath) {
1560
+ logger.error("Duplicate manifest target path: \(fileName)")
1561
+ self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
1562
+ let error = NSError(
1563
+ domain: "ManifestEntryError",
1564
+ code: 3,
1565
+ userInfo: [
1566
+ NSLocalizedDescriptionKey: "Duplicate manifest target path for \(fileName)"
1567
+ ]
1568
+ )
1569
+ errorLock.lock()
1570
+ if downloadError == nil {
1571
+ downloadError = error
1572
+ }
1573
+ errorLock.unlock()
1574
+ hasError.value = true
1575
+ continue
1576
+ }
1492
1577
  } catch {
1493
1578
  logger.error("Invalid manifest file path: \(fileName)")
1494
1579
  self.sendStats(action: "manifest_path_fail", versionName: "\(version):\(fileName)")
@@ -1501,8 +1586,40 @@ import UIKit
1501
1586
  continue
1502
1587
  }
1503
1588
 
1504
- // Create parent directories synchronously (before operations start)
1505
- try? FileManager.default.createDirectory(at: destFilePath.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
1589
+ tasks.append(
1590
+ ManifestDownloadTask(
1591
+ fileName: fileName,
1592
+ downloadUrl: downloadUrl,
1593
+ finalFileHash: finalFileHash,
1594
+ isBrotli: isBrotli,
1595
+ destFileName: destFileName,
1596
+ destFilePath: destFilePath,
1597
+ builtinFilePath: builtinFilePath,
1598
+ cacheFilePath: cacheFilePath,
1599
+ legacyCacheFilePath: legacyCacheFilePath
1600
+ )
1601
+ )
1602
+ }
1603
+
1604
+ if hasError.value {
1605
+ let resolvedError = downloadError ?? NSError(
1606
+ domain: "ManifestDownloadError",
1607
+ code: 1,
1608
+ userInfo: [NSLocalizedDescriptionKey: "Manifest download failed due to invalid or missing entries"]
1609
+ )
1610
+ let errorBundle = bundleInfo.setStatus(status: BundleStatus.ERROR.storedValue)
1611
+ self.saveBundleInfo(id: id, bundle: errorBundle)
1612
+ throw resolvedError
1613
+ }
1614
+
1615
+ var operations: [Operation] = []
1616
+
1617
+ for task in tasks {
1618
+ try FileManager.default.createDirectory(
1619
+ at: task.destFilePath.deletingLastPathComponent(),
1620
+ withIntermediateDirectories: true,
1621
+ attributes: nil
1622
+ )
1506
1623
 
1507
1624
  let operation = BlockOperation { [weak self] in
1508
1625
  guard let self = self else { return }
@@ -1510,26 +1627,29 @@ import UIKit
1510
1627
 
1511
1628
  do {
1512
1629
  // Try builtin first
1513
- if FileManager.default.fileExists(atPath: builtinFilePath.path) && self.verifyChecksum(file: builtinFilePath, expectedHash: finalFileHash) {
1514
- try self.copyItemReplacing(from: builtinFilePath, to: destFilePath)
1515
- self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
1630
+ if FileManager.default.fileExists(atPath: task.builtinFilePath.path) &&
1631
+ self.verifyChecksum(file: task.builtinFilePath, expectedHash: task.finalFileHash) {
1632
+ try self.copyItemReplacing(from: task.builtinFilePath, to: task.destFilePath)
1633
+ self.logger.info("downloadManifest \(task.fileName) using builtin file \(id)")
1516
1634
  }
1517
1635
  // Try cache
1518
1636
  else if
1519
- (cacheFilePath != nil && self.tryCopyFromCache(from: cacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) ||
1520
- (legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
1521
- self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
1637
+ (task.cacheFilePath != nil &&
1638
+ self.tryCopyFromCache(from: task.cacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) ||
1639
+ (task.legacyCacheFilePath != nil &&
1640
+ self.tryCopyFromCache(from: task.legacyCacheFilePath!, to: task.destFilePath, expectedHash: task.finalFileHash)) {
1641
+ self.logger.info("downloadManifest \(task.fileName) copy from cache \(id)")
1522
1642
  }
1523
1643
  // Download
1524
1644
  else {
1525
1645
  try self.downloadManifestFile(
1526
- downloadUrl: downloadUrl,
1527
- destFilePath: destFilePath,
1528
- cacheFilePath: cacheFilePath,
1529
- fileHash: finalFileHash,
1530
- fileName: fileName,
1531
- destFileName: destFileName,
1532
- isBrotli: isBrotli,
1646
+ downloadUrl: task.downloadUrl,
1647
+ destFilePath: task.destFilePath,
1648
+ cacheFilePath: task.cacheFilePath,
1649
+ fileHash: task.finalFileHash,
1650
+ fileName: task.fileName,
1651
+ destFileName: task.destFileName,
1652
+ isBrotli: task.isBrotli,
1533
1653
  sessionKey: sessionKey,
1534
1654
  version: version,
1535
1655
  bundleId: id
@@ -1547,8 +1667,8 @@ import UIKit
1547
1667
  }
1548
1668
  errorLock.unlock()
1549
1669
  hasError.value = true
1550
- self.logger.error("Manifest file download failed: \(fileName)")
1551
- self.logger.debug("Bundle: \(id), File: \(fileName), Error: \(error.localizedDescription)")
1670
+ self.logger.error("Manifest file download failed: \(task.fileName)")
1671
+ self.logger.debug("Bundle: \(id), File: \(task.fileName), Error: \(error.localizedDescription)")
1552
1672
  }
1553
1673
  }
1554
1674
 
@@ -1685,7 +1805,7 @@ import UIKit
1685
1805
 
1686
1806
  do {
1687
1807
  var source = partialURL
1688
- if !self.publicKey.isEmpty && !sessionKey.isEmpty {
1808
+ if !self.publicKey.isEmpty && CryptoCipher.isValidSessionKey(sessionKey) {
1689
1809
  let work = cacheFolder.appendingPathComponent("work_\(UUID().uuidString)_\((fileName as NSString).lastPathComponent)")
1690
1810
  try FileManager.default.copyItem(at: partialURL, to: work)
1691
1811
  workURL = work
@@ -1987,6 +2107,7 @@ import UIKit
1987
2107
  }
1988
2108
 
1989
2109
  public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
2110
+ try self.requireSessionKeyForEncryptedUpdate(sessionKey: sessionKey, versionName: version)
1990
2111
  try self.runBeforeDownload()
1991
2112
  let id: String = self.randomString(length: 10)
1992
2113
  // Each download uses its own temp files keyed by bundle ID to prevent collisions
@@ -2267,6 +2388,15 @@ import UIKit
2267
2388
  self.deleteLock.lock()
2268
2389
  defer { self.deleteLock.unlock() }
2269
2390
 
2391
+ let destPersist: URL
2392
+ do {
2393
+ destPersist = try self.getBundleDirectory(id: id)
2394
+ } catch {
2395
+ logger.error("Cannot delete bundle with invalid id")
2396
+ logger.debug("Bundle ID: \(id), Error: \(error.localizedDescription)")
2397
+ return false
2398
+ }
2399
+
2270
2400
  let deleted: BundleInfo = self.getBundleInfo(id: id)
2271
2401
  if deleted.isBuiltin() || self.getCurrentBundleId() == id {
2272
2402
  logger.info("Cannot delete current or builtin bundle")
@@ -2295,7 +2425,6 @@ import UIKit
2295
2425
  return false
2296
2426
  }
2297
2427
 
2298
- let destPersist: URL = libraryDir.appendingPathComponent(bundleDirectory).appendingPathComponent(id)
2299
2428
  let hadRegistry = self.hasStoredBundleInfo(id: id)
2300
2429
  let hadFolder = FileManager.default.fileExists(atPath: destPersist.path)
2301
2430
  if !hadRegistry && !hadFolder {
@@ -2599,8 +2728,8 @@ import UIKit
2599
2728
  }
2600
2729
  }
2601
2730
 
2602
- public func getBundleDirectory(id: String) -> URL {
2603
- return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
2731
+ public func getBundleDirectory(id: String) throws -> URL {
2732
+ return try Self.resolveBundleDirectory(libraryDir: libraryDir, bundleId: id)
2604
2733
  }
2605
2734
 
2606
2735
  struct ResetState {
@@ -2654,7 +2783,12 @@ import UIKit
2654
2783
  }
2655
2784
 
2656
2785
  private func bundleExists(id: String) -> Bool {
2657
- let destPersist: URL = self.getBundleDirectory(id: id)
2786
+ let destPersist: URL
2787
+ do {
2788
+ destPersist = try self.getBundleDirectory(id: id)
2789
+ } catch {
2790
+ return false
2791
+ }
2658
2792
  let indexPersist: URL = destPersist.appendingPathComponent("index.html")
2659
2793
  let bundleIndo: BundleInfo = self.getBundleInfo(id: id)
2660
2794
  if
@@ -2677,7 +2811,12 @@ import UIKit
2677
2811
  }
2678
2812
  if bundleExists(id: id) {
2679
2813
  let currentBundleName = self.getCurrentBundle().getVersionName()
2680
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: id).path)
2814
+ guard let bundleDir = try? self.getBundleDirectory(id: id) else {
2815
+ self.setBundleStatus(id: id, status: BundleStatus.ERROR)
2816
+ self.sendStats(action: "set_fail", versionName: newBundle.getVersionName())
2817
+ return false
2818
+ }
2819
+ self.setCurrentBundle(bundle: bundleDir.path)
2681
2820
  self.setBundleStatus(id: id, status: BundleStatus.PENDING)
2682
2821
  self.sendStats(action: "set", versionName: newBundle.getVersionName(), oldVersionName: currentBundleName)
2683
2822
  return true
@@ -2691,7 +2830,10 @@ import UIKit
2691
2830
  guard !bundle.isBuiltin(), bundleExists(id: bundle.getId()) else {
2692
2831
  return false
2693
2832
  }
2694
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2833
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
2834
+ return false
2835
+ }
2836
+ self.setCurrentBundle(bundle: bundleDir.path)
2695
2837
  return true
2696
2838
  }
2697
2839
 
@@ -2706,7 +2848,10 @@ import UIKit
2706
2848
  guard bundleExists(id: bundle.getId()) else {
2707
2849
  return false
2708
2850
  }
2709
- self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2851
+ guard let bundleDir = try? self.getBundleDirectory(id: bundle.getId()) else {
2852
+ return false
2853
+ }
2854
+ self.setCurrentBundle(bundle: bundleDir.path)
2710
2855
  return true
2711
2856
  }
2712
2857
 
@@ -225,8 +225,16 @@ public struct CryptoCipher {
225
225
  return String(hexString(from: sha256).prefix(16))
226
226
  }
227
227
 
228
+ public static func isValidSessionKey(_ sessionKey: String) -> Bool {
229
+ if sessionKey.isEmpty {
230
+ return false
231
+ }
232
+ let sessionKeyParts = sessionKey.components(separatedBy: ":")
233
+ return sessionKeyParts.count == 2 && !sessionKeyParts[0].isEmpty && !sessionKeyParts[1].isEmpty
234
+ }
235
+
228
236
  public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
229
- if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
237
+ if publicKey.isEmpty || !isValidSessionKey(sessionKey) {
230
238
  logger.info("Encryption not set, no public key or session, ignored")
231
239
  return
232
240
  }
@@ -359,7 +359,10 @@ extension UIWindow {
359
359
  if !next.isBuiltin() {
360
360
  plugin.logger.info("Resetting to: \(next.toString())")
361
361
  _ = updater.set(bundle: next)
362
- let destHot = updater.getBundleDirectory(id: next.getId())
362
+ guard let destHot = try? updater.getBundleDirectory(id: next.getId()) else {
363
+ resetBuiltin()
364
+ return
365
+ }
363
366
  plugin.logger.info("Reloading \(next.toString())")
364
367
  bridge.setServerBasePath(destHot.path)
365
368
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.51.21",
3
+ "version": "8.51.23",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",