expo-updates 57.0.12 → 57.0.14

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,20 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 57.0.14 — 2026-08-14
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - Reject updates whose asset key or file extension contains a path separator, which previously let a manifest write and delete files outside the updates directory. ([#48762](https://github.com/expo/expo/pull/48762), [#48763](https://github.com/expo/expo/pull/48763) by [@alanjhughes](https://github.com/alanjhughes))
18
+
19
+ ### 💡 Others
20
+
21
+ - [iOS] Link `libc++` in the test spec so the unit test bundle resolves the C++ symbols it pulls from `ExpoModulesCore`. ([#48762](https://github.com/expo/expo/pull/48762) by [@alanjhughes](https://github.com/alanjhughes))
22
+
23
+ ## 57.0.13 — 2026-08-10
24
+
25
+ _This version does not introduce any user-facing changes._
26
+
13
27
  ## 57.0.12 — 2026-08-04
14
28
 
15
29
  ### 🐛 Bug fixes
@@ -42,7 +42,7 @@ expoModule {
42
42
  }
43
43
 
44
44
  group = 'host.exp.exponent'
45
- version = '57.0.12'
45
+ version = '57.0.14'
46
46
 
47
47
  // Utility method to derive boolean values from the environment or from Java properties,
48
48
  // and return them as strings to be used in BuildConfig fields
@@ -89,7 +89,7 @@ android {
89
89
  namespace "expo.modules.updates"
90
90
  defaultConfig {
91
91
  versionCode 31
92
- versionName '57.0.12'
92
+ versionName '57.0.14'
93
93
  consumerProguardFiles("proguard-rules.pro")
94
94
  testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
95
95
 
@@ -166,6 +166,17 @@ object UpdatesUtils {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
171
+ * separator or a `..` component would resolve outside the updates directory.
172
+ */
173
+ fun isSafeFilename(filename: String): Boolean {
174
+ return filename.isNotEmpty() &&
175
+ filename != "." &&
176
+ filename != ".." &&
177
+ filename.none { it == '/' || it == '\\' || it == '\u0000' }
178
+ }
179
+
169
180
  fun shouldCheckForUpdateOnLaunch(
170
181
  updatesConfiguration: UpdatesConfiguration,
171
182
  logger: UpdatesLogger,
@@ -2,10 +2,12 @@ package expo.modules.updates.db
2
2
 
3
3
  import android.util.Log
4
4
  import expo.modules.updates.UpdatesConfiguration
5
+ import expo.modules.updates.UpdatesUtils
5
6
  import expo.modules.updates.db.entity.AssetEntity
6
7
  import expo.modules.updates.db.entity.UpdateEntity
7
8
  import expo.modules.updates.manifest.ManifestMetadata
8
9
  import expo.modules.updates.selectionpolicy.SelectionPolicy
10
+ import expo.modules.updates.utils.AndroidResourceAssetUtils
9
11
  import java.io.File
10
12
 
11
13
  /**
@@ -47,7 +49,20 @@ object Reaper {
47
49
  )
48
50
  continue
49
51
  }
50
- val path = File(updatesDirectory, asset.relativePath)
52
+ val relativePath = asset.relativePath
53
+ // Embedded assets are served from the APK, so there is no file here to delete.
54
+ if (relativePath == null || AndroidResourceAssetUtils.isAndroidResourceAsset(relativePath)) {
55
+ continue
56
+ }
57
+ // A row written before asset filenames were validated may point outside the updates directory.
58
+ if (!UpdatesUtils.isSafeFilename(relativePath)) {
59
+ Log.e(
60
+ TAG,
61
+ "Refusing to delete asset with URL " + asset.url + " at unsafe path " + relativePath
62
+ )
63
+ continue
64
+ }
65
+ val path = File(updatesDirectory, relativePath)
51
66
  try {
52
67
  if (path.exists() && !path.delete()) {
53
68
  Log.e(TAG, "Failed to delete asset with URL " + asset.url + " at path " + path.toString())
@@ -687,6 +687,12 @@ class FileDownloader(
687
687
  }
688
688
 
689
689
  val filename = UpdatesUtils.createFilenameForAsset(asset)
690
+ if (!UpdatesUtils.isSafeFilename(filename)) {
691
+ val message = "Failed to download asset ${asset.key}"
692
+ val error = IOException("Asset filename \"$filename\" would resolve outside the updates directory")
693
+ logger.error(message, error, UpdatesErrorCode.AssetsFailedToLoad)
694
+ throw IOException(message, error)
695
+ }
690
696
  val path = File(destinationDirectory, filename)
691
697
 
692
698
  if (path.exists()) {
@@ -14,6 +14,7 @@ import expo.modules.updates.db.enums.UpdateStatus
14
14
  import org.json.JSONArray
15
15
  import org.json.JSONException
16
16
  import org.json.JSONObject
17
+ import java.io.IOException
17
18
  import java.text.ParseException
18
19
  import java.util.*
19
20
 
@@ -95,27 +96,43 @@ class ExpoUpdatesUpdate private constructor(
95
96
  companion object {
96
97
  private val TAG = Update::class.java.simpleName
97
98
 
98
- @Throws(JSONException::class)
99
+ @Throws(Exception::class)
99
100
  fun fromExpoUpdatesManifest(
100
101
  manifest: ExpoUpdatesManifest,
101
102
  extensions: JSONObject?,
102
103
  configuration: UpdatesConfiguration
103
- ): ExpoUpdatesUpdate = ExpoUpdatesUpdate(
104
- manifest,
105
- id = UUID.fromString(manifest.getID()),
106
- configuration.scopeKey,
107
- commitTime = try {
108
- UpdatesUtils.parseDateString(manifest.getCreatedAt())
109
- } catch (e: ParseException) {
110
- Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
111
- Date()
112
- },
113
- runtimeVersion = manifest.getRuntimeVersion(),
114
- launchAsset = manifest.getLaunchAsset(),
115
- assets = manifest.getAssets(),
116
- extensions = extensions,
117
- url = configuration.updateUrl,
118
- requestHeaders = configuration.requestHeaders
119
- )
104
+ ): ExpoUpdatesUpdate {
105
+ val update = ExpoUpdatesUpdate(
106
+ manifest,
107
+ id = UUID.fromString(manifest.getID()),
108
+ configuration.scopeKey,
109
+ commitTime = try {
110
+ UpdatesUtils.parseDateString(manifest.getCreatedAt())
111
+ } catch (e: ParseException) {
112
+ Log.e(TAG, "Could not parse manifest createdAt string; falling back to current time", e)
113
+ Date()
114
+ },
115
+ runtimeVersion = manifest.getRuntimeVersion(),
116
+ launchAsset = manifest.getLaunchAsset(),
117
+ assets = manifest.getAssets(),
118
+ extensions = extensions,
119
+ url = configuration.updateUrl,
120
+ requestHeaders = configuration.requestHeaders
121
+ )
122
+
123
+ update.assetEntityList.forEach { asset ->
124
+ val filename = UpdatesUtils.createFilenameForAsset(asset)
125
+ if (!UpdatesUtils.isSafeFilename(filename)) {
126
+ throw IOException(
127
+ "Update ${manifest.getID()} could not be loaded because the asset filename " +
128
+ "\"$filename\" is not a valid filename. Assets are stored under their key and file " +
129
+ "extension, so neither may contain a path separator. Check the server that produced " +
130
+ "this manifest."
131
+ )
132
+ }
133
+ }
134
+
135
+ return update
136
+ }
120
137
  }
121
138
  }
@@ -105,6 +105,13 @@ public final class RemoteAppLoader: AppLoader {
105
105
  }
106
106
 
107
107
  override public func downloadAsset(_ asset: UpdateAsset, extraHeaders: [String: Any]) {
108
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
109
+ self.handleAssetDownload(
110
+ withError: UpdatesError.remoteAppLoaderUnsafeAssetFilename(filename: asset.filename),
111
+ asset: asset
112
+ )
113
+ return
114
+ }
108
115
  let urlOnDisk = self.directory.appendingPathComponent(asset.filename)
109
116
 
110
117
  let progressBlock = { [weak self] fractionCompleted in
@@ -71,6 +71,10 @@ public final class UpdatesReaper: NSObject {
71
71
 
72
72
  let beginDeleteAssets = Date()
73
73
  for asset in assetsForDeletion {
74
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
75
+ logger.warn(message: "Refusing to delete asset at unsafe path \(asset.filename)")
76
+ continue
77
+ }
74
78
  let localUrl = directory.appendingPathComponent(asset.filename)
75
79
  if FileManager.default.fileExists(atPath: localUrl.path) {
76
80
  do {
@@ -77,6 +77,7 @@ public enum UpdateStatus: Int {
77
77
  public enum UpdateError: Error, Sendable, LocalizedError {
78
78
  case invalidExpoProtocolVersion(protocolVersion: Int)
79
79
  case legacyManifestInstantiationInvalid
80
+ case unsafeAssetFilename(updateId: String, filename: String)
80
81
 
81
82
  public var errorDescription: String? {
82
83
  switch self {
@@ -84,6 +85,10 @@ public enum UpdateError: Error, Sendable, LocalizedError {
84
85
  return "Invalid Expo Updates protocol version: \(protocolVersion)"
85
86
  case .legacyManifestInstantiationInvalid:
86
87
  return "This version of expo-updates can no longer load legacy manifests"
88
+ case let .unsafeAssetFilename(updateId, filename):
89
+ return "Update \(updateId) could not be loaded because the asset filename \"\(filename)\" " +
90
+ "is not a valid filename. Assets are stored under their key and file extension, so neither " +
91
+ "may contain a path separator. Check the server that produced this manifest."
87
92
  }
88
93
  }
89
94
  }
@@ -157,12 +162,21 @@ public class Update: NSObject {
157
162
  }
158
163
  switch protocolVersion {
159
164
  case 0, 1:
160
- return ExpoUpdatesUpdate.update(
165
+ let update = ExpoUpdatesUpdate.update(
161
166
  withExpoUpdatesManifest: ExpoUpdatesManifest(rawManifestJSON: withManifest),
162
167
  extensions: extensions,
163
168
  config: config,
164
169
  database: database
165
170
  )
171
+ try update.assets()?.forEach { asset in
172
+ guard UpdatesUtils.isSafeFilename(asset.filename) else {
173
+ throw UpdateError.unsafeAssetFilename(
174
+ updateId: update.updateId.uuidString,
175
+ filename: asset.filename
176
+ )
177
+ }
178
+ }
179
+ return update
166
180
  default:
167
181
  throw UpdateError.invalidExpoProtocolVersion(protocolVersion: protocolVersion)
168
182
  }
@@ -26,6 +26,7 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
26
26
  case fileDownloaderFailedUpdateIDsFailure(cause: Error)
27
27
  case fileDownloaderUnknownError(cause: Error)
28
28
  case remoteAppLoaderAssetMissingUrl
29
+ case remoteAppLoaderUnsafeAssetFilename(filename: String)
29
30
  case remoteAppLoaderHeaderDataError(cause: Error)
30
31
  case remoteAppLoaderUnknownError(cause: Error)
31
32
  case appLoaderFailedToLoadAllAssets
@@ -92,6 +93,8 @@ public enum UpdatesError: Error, Sendable, LocalizedError {
92
93
  return "Unknown error: \(cause.localizedDescription)"
93
94
  case .remoteAppLoaderAssetMissingUrl:
94
95
  return "Failed to download asset with no URL provided"
96
+ case let .remoteAppLoaderUnsafeAssetFilename(filename):
97
+ return "Failed to download asset: filename \"\(filename)\" would resolve outside the updates directory"
95
98
  case let .remoteAppLoaderHeaderDataError(cause):
96
99
  return "Error persisting header data to disk: \(cause.localizedDescription)"
97
100
  case .appLoaderFailedToLoadAllAssets:
@@ -75,6 +75,23 @@ public final class UpdatesUtils: NSObject {
75
75
  return updatesDirectory
76
76
  }
77
77
 
78
+ /**
79
+ * Asset filenames are built from manifest-controlled values, so a filename holding a path
80
+ * separator or a `..` component would resolve outside the updates directory.
81
+ *
82
+ * Matching is done on unicode scalars because `String.contains` compares grapheme clusters, and
83
+ * a separator followed by a combining mark forms one cluster that does not equal the separator.
84
+ * The filesystem still sees the separator byte.
85
+ */
86
+ public static func isSafeFilename(_ filename: String) -> Bool {
87
+ return !filename.isEmpty &&
88
+ filename != "." &&
89
+ filename != ".." &&
90
+ !filename.unicodeScalars.contains("/") &&
91
+ !filename.unicodeScalars.contains("\\") &&
92
+ !filename.unicodeScalars.contains("\0")
93
+ }
94
+
78
95
  // MARK: - Internal methods
79
96
 
80
97
  public static func defaultNativeStateMachineContextJson() -> [String: Any?] {
@@ -141,6 +141,7 @@ Pod::Spec.new do |s|
141
141
  test_spec.dependency 'ExpoModulesTestCore'
142
142
 
143
143
  test_spec.pod_target_xcconfig = {
144
+ 'OTHER_LDFLAGS' => '$(inherited) -lc++',
144
145
  'USER_HEADER_SEARCH_PATHS' => '"${CONFIGURATION_TEMP_DIR}/EXUpdates.build/DerivedSources"',
145
146
  'GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS' => 'YES',
146
147
  'GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS' => 'YES',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-updates",
3
- "version": "57.0.12",
3
+ "version": "57.0.14",
4
4
  "description": "Fetches and manages remotely-hosted assets and updates to your app's JS bundle.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -60,10 +60,10 @@
60
60
  "picomatch": "^4.0.4",
61
61
  "ts-node": "^10.9.2",
62
62
  "xstate": "^4.37.2",
63
- "@expo/metro-config": "57.0.7",
64
- "expo": "57.0.10",
65
- "expo-module-scripts": "56.0.3",
66
- "expo-dev-client": "57.0.10"
63
+ "@expo/metro-config": "57.0.8",
64
+ "expo-dev-client": "57.0.12",
65
+ "expo": "57.0.13",
66
+ "expo-module-scripts": "56.0.3"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "expo": "*",
@@ -82,7 +82,7 @@
82
82
  "./scripts/with-node.sh"
83
83
  ]
84
84
  },
85
- "gitHead": "fb5049481ebd5cadd499c4d1f1d5e47f9ac678be",
85
+ "gitHead": "125b8d4472432db3dc4ae047730432d7bd5bd30c",
86
86
  "scripts": {
87
87
  "build": "expo-module build",
88
88
  "clean": "expo-module clean",