@hot-updater/react-native 0.31.4 → 0.33.0
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/android/build.gradle +5 -1
- package/android/libs/README.md +19 -6
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +13 -5
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +24 -38
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +62 -0
- package/android/src/main/java/com/hotupdater/RelativePathResolver.kt +41 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +11 -12
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +3 -3
- package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +2 -2
- package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +9 -14
- package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +47 -0
- package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +20 -0
- package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +11 -11
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +20 -3
- package/lib/commonjs/DefaultResolver.spec.js +18 -10
- package/lib/commonjs/DefaultResolver.spec.js.map +1 -1
- package/lib/commonjs/checkForUpdate.js +9 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.spec.js +29 -24
- package/lib/commonjs/index.spec.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/module/DefaultResolver.spec.js +18 -9
- package/lib/module/DefaultResolver.spec.js.map +1 -1
- package/lib/module/checkForUpdate.js +9 -0
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.spec.js +29 -24
- package/lib/module/index.spec.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/package.json +7 -7
- package/plugin/build/withHotUpdater.js +52 -41
- package/src/DefaultResolver.spec.ts +25 -10
- package/src/checkForUpdate.ts +10 -0
- package/src/index.spec.ts +32 -24
- package/src/index.ts +1 -0
- package/src/sdkVersion.ts +1 -1
- package/android/libs/org.brotli.dec-1.2.0.jar +0 -0
package/android/build.gradle
CHANGED
|
@@ -69,6 +69,10 @@ android {
|
|
|
69
69
|
consumerProguardFiles 'proguard-rules.pro'
|
|
70
70
|
buildConfigField "long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L"
|
|
71
71
|
|
|
72
|
+
ndk {
|
|
73
|
+
abiFilters(*reactNativeArchitectures())
|
|
74
|
+
}
|
|
75
|
+
|
|
72
76
|
externalNativeBuild {
|
|
73
77
|
cmake {
|
|
74
78
|
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
|
@@ -146,7 +150,7 @@ dependencies {
|
|
|
146
150
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
|
147
151
|
implementation "org.apache.commons:commons-compress:1.27.1"
|
|
148
152
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
|
149
|
-
implementation files('libs/
|
|
153
|
+
implementation files('libs/hot-updater-brotli-dec-1.2.0.jar')
|
|
150
154
|
testImplementation "junit:junit:4.13.2"
|
|
151
155
|
testImplementation "org.json:json:20240303"
|
|
152
156
|
}
|
package/android/libs/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bundled Libraries
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## hot-updater-brotli-dec-1.2.0.jar
|
|
4
4
|
|
|
5
5
|
**Why bundled:** The official `org.brotli:dec` package on Maven Central is stuck at v0.1.2 (released May 2017) and contains critical security vulnerabilities. Google has not published newer versions to Maven Central despite releasing v1.2.0 with security fixes.
|
|
6
6
|
|
|
@@ -18,15 +18,28 @@ mvn clean package -DskipTests
|
|
|
18
18
|
cp target/org.brotli.dec-1.2.0-SNAPSHOT.jar <destination>/org.brotli.dec-1.2.0.jar
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
The generated jar is then relocated before being bundled:
|
|
22
|
+
|
|
23
|
+
```gradle
|
|
24
|
+
relocate "org.brotli.dec", "com.hotupdater.vendor.brotli.dec"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The relocation keeps Hot Updater's security-fixed Brotli decoder isolated from
|
|
28
|
+
apps or frameworks that also depend on `org.brotli:dec`.
|
|
29
|
+
|
|
21
30
|
**Verification:**
|
|
22
|
-
- **SHA256:** `2d12a2d7fb52fd7f944564fb378aaccd41389cf02ea2e5eb6b3a6477188cced8`
|
|
23
|
-
- **
|
|
31
|
+
- **Original SHA256:** `2d12a2d7fb52fd7f944564fb378aaccd41389cf02ea2e5eb6b3a6477188cced8`
|
|
32
|
+
- **Relocated SHA256:** `4e9aee81466e21e365fd291bd29ca0540dc174ce64d29a7613ff336e4276bcdf`
|
|
33
|
+
- **Size:** 99KB
|
|
24
34
|
- **Built:** 2026-02-12
|
|
25
35
|
- **Git Commit:** [028fb5a](https://github.com/google/brotli/commit/028fb5a23661f123017c060daa546b55cf4bde29) (v1.2.0 tag)
|
|
36
|
+
- **Relocated Package:** `com.hotupdater.vendor.brotli.dec`
|
|
26
37
|
|
|
27
38
|
**API Compatibility:**
|
|
28
|
-
The v1.2.0 decoder is
|
|
29
|
-
|
|
39
|
+
The v1.2.0 decoder is API compatible with v0.1.2. Hot Updater imports the
|
|
40
|
+
relocated package internally, so apps can keep their own `org.brotli:dec`
|
|
41
|
+
dependency without duplicate classes:
|
|
42
|
+
- Relocated package: `com.hotupdater.vendor.brotli.dec`
|
|
30
43
|
- Same class: `BrotliInputStream`
|
|
31
44
|
- Same constructor: `BrotliInputStream(InputStream)`
|
|
32
45
|
|
|
@@ -34,4 +47,4 @@ The v1.2.0 decoder is 100% API compatible with v0.1.2. No code changes are requi
|
|
|
34
47
|
Apache Commons Compress depends on `org.brotli:dec:0.1.2` as an optional dependency, so switching to it would not fix the vulnerability—it would just make it transitive.
|
|
35
48
|
|
|
36
49
|
**Future Updates:**
|
|
37
|
-
When Google releases new versions of Brotli (e.g., v1.3.0), rebuild this JAR using the same process and update the SHA256 checksum and build date in this file.
|
|
50
|
+
When Google releases new versions of Brotli (e.g., v1.3.0), rebuild this JAR using the same process, relocate it into the Hot Updater vendor package, and update the SHA256 checksum and build date in this file.
|
|
Binary file
|
|
@@ -2,9 +2,9 @@ package com.hotupdater
|
|
|
2
2
|
|
|
3
3
|
import android.os.StatFs
|
|
4
4
|
import android.util.Log
|
|
5
|
+
import com.hotupdater.vendor.brotli.dec.BrotliInputStream
|
|
5
6
|
import kotlinx.coroutines.Dispatchers
|
|
6
7
|
import kotlinx.coroutines.withContext
|
|
7
|
-
import org.brotli.dec.BrotliInputStream
|
|
8
8
|
import org.json.JSONObject
|
|
9
9
|
import java.io.File
|
|
10
10
|
import java.io.FileInputStream
|
|
@@ -391,7 +391,7 @@ class BundleFileStorageService(
|
|
|
391
391
|
}
|
|
392
392
|
|
|
393
393
|
val sourceDir = activeBundleDir ?: return false
|
|
394
|
-
val sourceFile =
|
|
394
|
+
val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return false
|
|
395
395
|
if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
|
|
396
396
|
return false
|
|
397
397
|
}
|
|
@@ -735,11 +735,15 @@ class BundleFileStorageService(
|
|
|
735
735
|
if (assetPath !is String) {
|
|
736
736
|
return null
|
|
737
737
|
}
|
|
738
|
+
val normalizedAssetPath = RelativePathResolver.normalizeRelativePath(assetPath)
|
|
739
|
+
if (normalizedAssetPath == null || normalizedAssetPath != assetPath) {
|
|
740
|
+
return null
|
|
741
|
+
}
|
|
738
742
|
|
|
739
743
|
val assetMap = assetValue as? Map<*, *> ?: return null
|
|
740
744
|
val fileHash = assetMap["fileHash"] as? String ?: return null
|
|
741
745
|
val signature = assetMap["signature"] as? String
|
|
742
|
-
assets[
|
|
746
|
+
assets[normalizedAssetPath] =
|
|
743
747
|
ParsedManifestAsset(
|
|
744
748
|
fileHash = fileHash,
|
|
745
749
|
signature = signature?.takeIf { it.isNotBlank() },
|
|
@@ -1523,7 +1527,9 @@ class BundleFileStorageService(
|
|
|
1523
1527
|
val targetEntries = targetManifest.assets.entries.toList()
|
|
1524
1528
|
targetEntries.forEachIndexed { index, (assetPath, expectedAsset) ->
|
|
1525
1529
|
val expectedHash = expectedAsset.fileHash
|
|
1526
|
-
val targetFile =
|
|
1530
|
+
val targetFile =
|
|
1531
|
+
RelativePathResolver.resolveInside(tmpDir, assetPath)
|
|
1532
|
+
?: throw HotUpdaterException.invalidBundle()
|
|
1527
1533
|
val currentAsset = currentManifest?.assets?.get(assetPath)
|
|
1528
1534
|
|
|
1529
1535
|
if (currentAsset?.fileHash == expectedHash) {
|
|
@@ -1532,7 +1538,9 @@ class BundleFileStorageService(
|
|
|
1532
1538
|
?: throw HotUpdaterException.downloadFailed(
|
|
1533
1539
|
IllegalStateException("Current bundle directory unavailable for reused asset: $assetPath"),
|
|
1534
1540
|
)
|
|
1535
|
-
val sourceFile =
|
|
1541
|
+
val sourceFile =
|
|
1542
|
+
RelativePathResolver.resolveInside(sourceDir, assetPath)
|
|
1543
|
+
?: throw HotUpdaterException.invalidBundle()
|
|
1536
1544
|
if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, expectedHash)) {
|
|
1537
1545
|
throw HotUpdaterException.downloadFailed(
|
|
1538
1546
|
IllegalStateException("Reusable asset missing or corrupted: $assetPath"),
|
|
@@ -105,14 +105,7 @@ class HotUpdaterImpl {
|
|
|
105
105
|
* @return The isolation key in format: HotUpdaterPrefs_{fingerprintOrVersion}_{channel}
|
|
106
106
|
*/
|
|
107
107
|
private fun getIsolationKey(context: Context): String {
|
|
108
|
-
|
|
109
|
-
val fingerprintId = StringResourceUtils.getIdentifier(context, "hot_updater_fingerprint_hash")
|
|
110
|
-
val fingerprintHash =
|
|
111
|
-
if (fingerprintId != 0) {
|
|
112
|
-
context.getString(fingerprintId).takeIf { it.isNotEmpty() }
|
|
113
|
-
} else {
|
|
114
|
-
null
|
|
115
|
-
}
|
|
108
|
+
val fingerprintHash = getFingerprintHash(context)
|
|
116
109
|
|
|
117
110
|
// Get app version and channel
|
|
118
111
|
val appVersion = getAppVersion(context) ?: "unknown"
|
|
@@ -147,14 +140,12 @@ class HotUpdaterImpl {
|
|
|
147
140
|
null
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
fun getChannel(context: Context): String
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
}
|
|
143
|
+
fun getChannel(context: Context): String =
|
|
144
|
+
NativeConfigUtils.getString(
|
|
145
|
+
context,
|
|
146
|
+
NativeConfigUtils.CHANNEL_META_DATA_KEY,
|
|
147
|
+
"hot_updater_channel",
|
|
148
|
+
) ?: DEFAULT_CHANNEL
|
|
158
149
|
|
|
159
150
|
/**
|
|
160
151
|
* Get minimum bundle ID string
|
|
@@ -221,28 +212,24 @@ class HotUpdaterImpl {
|
|
|
221
212
|
* @param context Application context
|
|
222
213
|
* @return The fingerprint hash or null if not set
|
|
223
214
|
*/
|
|
224
|
-
fun getFingerprintHash(context: Context): String?
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
}
|
|
215
|
+
fun getFingerprintHash(context: Context): String? =
|
|
216
|
+
NativeConfigUtils.getString(
|
|
217
|
+
context,
|
|
218
|
+
NativeConfigUtils.FINGERPRINT_HASH_META_DATA_KEY,
|
|
219
|
+
"hot_updater_fingerprint_hash",
|
|
220
|
+
)
|
|
232
221
|
}
|
|
233
222
|
|
|
234
223
|
/**
|
|
235
224
|
* Gets the current fingerprint hash
|
|
236
225
|
* @return The fingerprint hash or null if not set
|
|
237
226
|
*/
|
|
238
|
-
fun getFingerprintHash(): String?
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
}
|
|
227
|
+
fun getFingerprintHash(): String? =
|
|
228
|
+
NativeConfigUtils.getString(
|
|
229
|
+
context,
|
|
230
|
+
NativeConfigUtils.FINGERPRINT_HASH_META_DATA_KEY,
|
|
231
|
+
"hot_updater_fingerprint_hash",
|
|
232
|
+
)
|
|
246
233
|
|
|
247
234
|
/**
|
|
248
235
|
* Gets the current update channel
|
|
@@ -254,12 +241,11 @@ class HotUpdaterImpl {
|
|
|
254
241
|
return overriddenChannel
|
|
255
242
|
}
|
|
256
243
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
244
|
+
return NativeConfigUtils.getString(
|
|
245
|
+
context,
|
|
246
|
+
NativeConfigUtils.CHANNEL_META_DATA_KEY,
|
|
247
|
+
"hot_updater_channel",
|
|
248
|
+
) ?: DEFAULT_CHANNEL
|
|
263
249
|
}
|
|
264
250
|
|
|
265
251
|
fun getDefaultChannel(): String = getChannel(context)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
|
|
7
|
+
object NativeConfigUtils {
|
|
8
|
+
const val CHANNEL_META_DATA_KEY = "com.hotupdater.CHANNEL"
|
|
9
|
+
const val FINGERPRINT_HASH_META_DATA_KEY = "com.hotupdater.FINGERPRINT_HASH"
|
|
10
|
+
const val PUBLIC_KEY_META_DATA_KEY = "com.hotupdater.PUBLIC_KEY"
|
|
11
|
+
|
|
12
|
+
fun getString(
|
|
13
|
+
context: Context,
|
|
14
|
+
metaDataKey: String,
|
|
15
|
+
stringResourceName: String,
|
|
16
|
+
): String? =
|
|
17
|
+
getManifestMetaDataString(context, metaDataKey)
|
|
18
|
+
?: getStringResource(context, stringResourceName)
|
|
19
|
+
|
|
20
|
+
private fun getManifestMetaDataString(
|
|
21
|
+
context: Context,
|
|
22
|
+
key: String,
|
|
23
|
+
): String? =
|
|
24
|
+
try {
|
|
25
|
+
val packageManager = context.packageManager
|
|
26
|
+
val applicationInfo =
|
|
27
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
28
|
+
packageManager.getApplicationInfo(
|
|
29
|
+
context.packageName,
|
|
30
|
+
PackageManager.ApplicationInfoFlags.of(PackageManager.GET_META_DATA.toLong()),
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
@Suppress("DEPRECATION")
|
|
34
|
+
packageManager.getApplicationInfo(
|
|
35
|
+
context.packageName,
|
|
36
|
+
PackageManager.GET_META_DATA,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Suppress("DEPRECATION")
|
|
41
|
+
coerceManifestMetaDataString(applicationInfo.metaData?.get(key))
|
|
42
|
+
} catch (e: Exception) {
|
|
43
|
+
null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
internal fun coerceManifestMetaDataString(value: Any?): String? =
|
|
47
|
+
value
|
|
48
|
+
?.toString()
|
|
49
|
+
?.takeIf { it.isNotEmpty() }
|
|
50
|
+
|
|
51
|
+
private fun getStringResource(
|
|
52
|
+
context: Context,
|
|
53
|
+
name: String,
|
|
54
|
+
): String? {
|
|
55
|
+
val id = StringResourceUtils.getIdentifier(context, name)
|
|
56
|
+
return if (id != 0) {
|
|
57
|
+
context.getString(id).takeIf { it.isNotEmpty() }
|
|
58
|
+
} else {
|
|
59
|
+
null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
|
|
5
|
+
internal object RelativePathResolver {
|
|
6
|
+
fun normalizeRelativePath(path: String): String? {
|
|
7
|
+
if (path.isBlank() || path.indexOf('\u0000') >= 0 || path.contains('\\')) {
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (File(path).isAbsolute || Regex("^[A-Za-z]:").containsMatchIn(path)) {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
val components = path.split("/")
|
|
16
|
+
if (components.any { it.isEmpty() || it == "." || it == ".." }) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return components.joinToString("/")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fun resolveInside(
|
|
24
|
+
root: File,
|
|
25
|
+
relativePath: String,
|
|
26
|
+
): File? {
|
|
27
|
+
val normalizedPath = normalizeRelativePath(relativePath) ?: return null
|
|
28
|
+
val file = File(root, normalizedPath)
|
|
29
|
+
return if (isInside(root, file)) file else null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fun isInside(
|
|
33
|
+
root: File,
|
|
34
|
+
file: File,
|
|
35
|
+
): Boolean {
|
|
36
|
+
val rootPath = root.canonicalFile.path
|
|
37
|
+
val filePath = file.canonicalFile.path
|
|
38
|
+
return filePath == rootPath ||
|
|
39
|
+
filePath.startsWith("$rootPath${File.separator}")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -27,7 +27,7 @@ sealed class SignatureVerificationException(
|
|
|
27
27
|
class PublicKeyNotConfigured :
|
|
28
28
|
SignatureVerificationException(
|
|
29
29
|
"Public key not configured for signature verification. " +
|
|
30
|
-
"Add '
|
|
30
|
+
"Add 'com.hotupdater.PUBLIC_KEY' to AndroidManifest.xml",
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
class InvalidPublicKeyFormat :
|
|
@@ -97,16 +97,15 @@ object SignatureVerifier {
|
|
|
97
97
|
* @return Public key PEM string or null if not configured
|
|
98
98
|
*/
|
|
99
99
|
private fun getPublicKeyFromConfig(context: Context): String? {
|
|
100
|
-
val
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
100
|
+
val publicKeyPEM =
|
|
101
|
+
NativeConfigUtils.getString(
|
|
102
|
+
context,
|
|
103
|
+
NativeConfigUtils.PUBLIC_KEY_META_DATA_KEY,
|
|
104
|
+
"hot_updater_public_key",
|
|
105
|
+
)
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
Log.d(TAG, "hot_updater_public_key is empty")
|
|
107
|
+
if (publicKeyPEM.isNullOrEmpty()) {
|
|
108
|
+
Log.d(TAG, "com.hotupdater.PUBLIC_KEY is not configured")
|
|
110
109
|
return null
|
|
111
110
|
}
|
|
112
111
|
|
|
@@ -229,7 +228,7 @@ object SignatureVerifier {
|
|
|
229
228
|
val publicKeyPEM =
|
|
230
229
|
getPublicKeyFromConfig(context)
|
|
231
230
|
?: run {
|
|
232
|
-
Log.e(TAG, "Cannot verify signature: public key not configured
|
|
231
|
+
Log.e(TAG, "Cannot verify signature: public key not configured")
|
|
233
232
|
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
234
233
|
}
|
|
235
234
|
|
|
@@ -267,7 +266,7 @@ object SignatureVerifier {
|
|
|
267
266
|
val publicKeyPEM =
|
|
268
267
|
getPublicKeyFromConfig(context)
|
|
269
268
|
?: run {
|
|
270
|
-
Log.e(TAG, "Cannot verify signature: public key not configured
|
|
269
|
+
Log.e(TAG, "Cannot verify signature: public key not configured")
|
|
271
270
|
throw SignatureVerificationException.PublicKeyNotConfigured()
|
|
272
271
|
}
|
|
273
272
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
package com.hotupdater
|
|
2
2
|
|
|
3
3
|
import android.util.Log
|
|
4
|
-
import
|
|
4
|
+
import com.hotupdater.vendor.brotli.dec.BrotliInputStream
|
|
5
5
|
import java.io.BufferedInputStream
|
|
6
6
|
import java.io.File
|
|
7
7
|
import java.io.FileInputStream
|
|
@@ -72,9 +72,9 @@ class TarBrDecompressionStrategy : DecompressionStrategy {
|
|
|
72
72
|
var entry = tarInputStream.getNextEntry()
|
|
73
73
|
|
|
74
74
|
while (entry != null) {
|
|
75
|
-
val file =
|
|
75
|
+
val file = RelativePathResolver.resolveInside(destinationDir, entry.name)
|
|
76
76
|
|
|
77
|
-
if (
|
|
77
|
+
if (file == null) {
|
|
78
78
|
Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
|
|
79
79
|
entry = tarInputStream.getNextEntry()
|
|
80
80
|
continue
|
|
@@ -73,9 +73,9 @@ class TarGzDecompressionStrategy : DecompressionStrategy {
|
|
|
73
73
|
var entry = tarInputStream.getNextEntry()
|
|
74
74
|
|
|
75
75
|
while (entry != null) {
|
|
76
|
-
val file =
|
|
76
|
+
val file = RelativePathResolver.resolveInside(destinationDir, entry.name)
|
|
77
77
|
|
|
78
|
-
if (
|
|
78
|
+
if (file == null) {
|
|
79
79
|
Log.w(TAG, "Skipping potentially malicious tar entry: ${entry.name}")
|
|
80
80
|
entry = tarInputStream.getNextEntry()
|
|
81
81
|
continue
|
|
@@ -117,21 +117,16 @@ class ZipDecompressionStrategy : DecompressionStrategy {
|
|
|
117
117
|
ZipInputStream(bufferedInputStream).use { zipInputStream ->
|
|
118
118
|
var entry: ZipEntry? = zipInputStream.nextEntry
|
|
119
119
|
while (entry != null) {
|
|
120
|
-
val file =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!canonicalFilePath.startsWith(canonicalDestPath)) {
|
|
128
|
-
Log.w(TAG, "Skipping potentially malicious zip entry: ${entry.name}")
|
|
129
|
-
entry = zipInputStream.nextEntry
|
|
130
|
-
continue
|
|
120
|
+
val file =
|
|
121
|
+
try {
|
|
122
|
+
RelativePathResolver.resolveInside(destinationDir, entry.name)
|
|
123
|
+
} catch (e: IOException) {
|
|
124
|
+
Log.w(TAG, "Failed to resolve canonical path for zip entry: ${entry.name}", e)
|
|
125
|
+
null
|
|
131
126
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
Log.w(TAG, "
|
|
127
|
+
|
|
128
|
+
if (file == null) {
|
|
129
|
+
Log.w(TAG, "Skipping potentially malicious zip entry: ${entry.name}")
|
|
135
130
|
entry = zipInputStream.nextEntry
|
|
136
131
|
continue
|
|
137
132
|
}
|
|
@@ -12,6 +12,8 @@ import org.junit.Test
|
|
|
12
12
|
import org.junit.rules.TemporaryFolder
|
|
13
13
|
import java.io.File
|
|
14
14
|
import java.net.URL
|
|
15
|
+
import java.util.zip.ZipEntry
|
|
16
|
+
import java.util.zip.ZipOutputStream
|
|
15
17
|
|
|
16
18
|
class BundleFileStorageServiceTest {
|
|
17
19
|
@get:Rule
|
|
@@ -253,6 +255,41 @@ class BundleFileStorageServiceTest {
|
|
|
253
255
|
assertTrue(invokeCanUseManifestDrivenInstall(service))
|
|
254
256
|
}
|
|
255
257
|
|
|
258
|
+
@Test
|
|
259
|
+
fun `manifest driven install rejects unsafe asset paths`() {
|
|
260
|
+
val rootDir = temporaryFolder.newFolder("active-ota-unsafe-manifest")
|
|
261
|
+
val preferences = InMemoryPreferencesService()
|
|
262
|
+
val service = createService(rootDir, preferences)
|
|
263
|
+
val activeDir = createBundleDir(rootDir, "active-bundle")
|
|
264
|
+
val activeBundleFile = writeFile(activeDir, "index.android.bundle")
|
|
265
|
+
writeManifest(activeDir, listOf("../active-bundle_evil/index.android.bundle"))
|
|
266
|
+
|
|
267
|
+
preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
|
|
268
|
+
|
|
269
|
+
assertFalse(invokeCanUseManifestDrivenInstall(service))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@Test
|
|
273
|
+
fun `zip decompression does not write sibling prefix traversal entries`() {
|
|
274
|
+
val rootDir = temporaryFolder.newFolder("zip-sibling-prefix")
|
|
275
|
+
val zipFile = File(rootDir, "bundle.zip")
|
|
276
|
+
ZipOutputStream(zipFile.outputStream()).use { zip ->
|
|
277
|
+
writeZipEntry(zip, "../bundle-temp_evil/escape.txt", "blocked")
|
|
278
|
+
writeZipEntry(zip, "safe/kept.txt", "kept")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
val destinationDir = File(rootDir, "bundle-temp")
|
|
282
|
+
val extracted =
|
|
283
|
+
ZipDecompressionStrategy().decompress(
|
|
284
|
+
zipFile.absolutePath,
|
|
285
|
+
destinationDir.absolutePath,
|
|
286
|
+
) {}
|
|
287
|
+
|
|
288
|
+
assertTrue(extracted)
|
|
289
|
+
assertTrue(File(destinationDir, "safe/kept.txt").isFile)
|
|
290
|
+
assertFalse(File(rootDir, "bundle-temp_evil/escape.txt").exists())
|
|
291
|
+
}
|
|
292
|
+
|
|
256
293
|
private fun createService(
|
|
257
294
|
rootDir: File,
|
|
258
295
|
preferences: InMemoryPreferencesService = InMemoryPreferencesService(),
|
|
@@ -313,6 +350,16 @@ class BundleFileStorageServiceTest {
|
|
|
313
350
|
writeText(content)
|
|
314
351
|
}
|
|
315
352
|
|
|
353
|
+
private fun writeZipEntry(
|
|
354
|
+
zip: ZipOutputStream,
|
|
355
|
+
path: String,
|
|
356
|
+
content: String,
|
|
357
|
+
) {
|
|
358
|
+
zip.putNextEntry(ZipEntry(path))
|
|
359
|
+
zip.write(content.toByteArray())
|
|
360
|
+
zip.closeEntry()
|
|
361
|
+
}
|
|
362
|
+
|
|
316
363
|
private fun bundleStoreDir(rootDir: File): File = File(rootDir, "bundle-store").apply { mkdirs() }
|
|
317
364
|
|
|
318
365
|
private fun invokeResolveBundleFile(
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package com.hotupdater
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertNull
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class NativeConfigUtilsTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun `coerces typed manifest metadata to strings`() {
|
|
10
|
+
assertEquals("123", NativeConfigUtils.coerceManifestMetaDataString(123))
|
|
11
|
+
assertEquals("true", NativeConfigUtils.coerceManifestMetaDataString(true))
|
|
12
|
+
assertEquals("-16711936", NativeConfigUtils.coerceManifestMetaDataString(-16711936))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun `ignores missing and empty manifest metadata`() {
|
|
17
|
+
assertNull(NativeConfigUtils.coerceManifestMetaDataString(null))
|
|
18
|
+
assertNull(NativeConfigUtils.coerceManifestMetaDataString(""))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -84,16 +84,14 @@ enum ArchiveExtractionUtilities {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
static func normalizedRelativePath(from rawPath: String) -> String? {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
while candidate.hasPrefix("./") {
|
|
96
|
-
candidate.removeFirst(2)
|
|
87
|
+
let candidate = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
88
|
+
guard !candidate.isEmpty,
|
|
89
|
+
!candidate.contains("\0"),
|
|
90
|
+
!candidate.contains("\\"),
|
|
91
|
+
!candidate.hasPrefix("/"),
|
|
92
|
+
candidate.range(of: #"^[A-Za-z]:"#, options: .regularExpression) == nil
|
|
93
|
+
else {
|
|
94
|
+
return nil
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
let components = candidate
|
|
@@ -104,7 +102,9 @@ enum ArchiveExtractionUtilities {
|
|
|
104
102
|
return nil
|
|
105
103
|
}
|
|
106
104
|
|
|
107
|
-
guard
|
|
105
|
+
guard components.count == candidate.split(separator: "/", omittingEmptySubsequences: false).count,
|
|
106
|
+
!components.contains(".."),
|
|
107
|
+
!components.contains(".") else {
|
|
108
108
|
return nil
|
|
109
109
|
}
|
|
110
110
|
|
|
@@ -556,7 +556,15 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
556
556
|
return false
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
let sourcePath
|
|
559
|
+
let sourcePath: String
|
|
560
|
+
do {
|
|
561
|
+
sourcePath = try ArchiveExtractionUtilities.extractionURL(
|
|
562
|
+
for: assetPath,
|
|
563
|
+
destinationRoot: currentBundleDir
|
|
564
|
+
).path
|
|
565
|
+
} catch {
|
|
566
|
+
return false
|
|
567
|
+
}
|
|
560
568
|
guard self.fileSystem.fileExists(atPath: sourcePath),
|
|
561
569
|
HashUtils.verifyHash(
|
|
562
570
|
fileURL: URL(fileURLWithPath: sourcePath),
|
|
@@ -969,6 +977,9 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
969
977
|
|
|
970
978
|
var assets: [String: ParsedManifestAsset] = [:]
|
|
971
979
|
for (assetPath, assetValue) in rawAssets {
|
|
980
|
+
guard ArchiveExtractionUtilities.normalizedRelativePath(from: assetPath) == assetPath else {
|
|
981
|
+
return nil
|
|
982
|
+
}
|
|
972
983
|
guard let asset = assetValue as? [String: Any],
|
|
973
984
|
let fileHash = asset["fileHash"] as? String,
|
|
974
985
|
!fileHash.isEmpty
|
|
@@ -1738,7 +1749,10 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1738
1749
|
let assetPath = asset.key
|
|
1739
1750
|
let expectedAsset = asset.value
|
|
1740
1751
|
let expectedHash = expectedAsset.fileHash
|
|
1741
|
-
let destinationPath =
|
|
1752
|
+
let destinationPath = try ArchiveExtractionUtilities.extractionURL(
|
|
1753
|
+
for: assetPath,
|
|
1754
|
+
destinationRoot: tmpDir
|
|
1755
|
+
).path
|
|
1742
1756
|
let destinationDir = (destinationPath as NSString).deletingLastPathComponent
|
|
1743
1757
|
guard self.fileSystem.createDirectory(atPath: destinationDir) else {
|
|
1744
1758
|
throw BundleStorageError.directoryCreationFailed
|
|
@@ -1755,7 +1769,10 @@ class BundleFileStorageService: BundleStorageService {
|
|
|
1755
1769
|
)
|
|
1756
1770
|
}
|
|
1757
1771
|
|
|
1758
|
-
let sourcePath =
|
|
1772
|
+
let sourcePath = try ArchiveExtractionUtilities.extractionURL(
|
|
1773
|
+
for: assetPath,
|
|
1774
|
+
destinationRoot: currentBundleDir
|
|
1775
|
+
).path
|
|
1759
1776
|
guard self.fileSystem.fileExists(atPath: sourcePath),
|
|
1760
1777
|
HashUtils.verifyHash(fileURL: URL(fileURLWithPath: sourcePath), expectedHash: expectedHash)
|
|
1761
1778
|
else {
|