@exodus/react-native-bundle-loader 0.2.0-exodus.2 → 0.2.0-exodus.4
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/README.md +5 -32
- package/SECURITY.md +11 -4
- package/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java +74 -122
- package/ios/BundleLoader.h +3 -0
- package/ios/BundleLoader.m +19 -14
- package/lib/commonjs/index.js +4 -75
- package/lib/module/index.js +5 -67
- package/lib/typescript/index.d.ts +0 -4
- package/package.json +1 -1
- package/src/index.tsx +4 -84
package/README.md
CHANGED
|
@@ -50,53 +50,26 @@ Behavior:
|
|
|
50
50
|
|
|
51
51
|
Works on iOS and Android.
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
BundleLoader.load('https://bundles.example.com/main.jsbundle');
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
Functionally identical to the upstream `load()`: passes the URL straight through to the native bridge, which fetches and reloads. **This skips integrity verification — only use it for developer ergonomics, never in production paths.**
|
|
60
|
-
|
|
61
|
-
The URL is required to use `https:`.
|
|
62
|
-
|
|
63
|
-
### `BundlePrompt`
|
|
64
|
-
|
|
65
|
-
A `Modal`-wrapped text input + Reload button intended for developer UX. The default URL field is **empty** (the upstream's hardcoded jsdelivr default has been removed). The button calls the unverified `load()` path.
|
|
66
|
-
|
|
67
|
-
```tsx
|
|
68
|
-
import { BundlePrompt } from '@exodus/react-native-bundle-loader';
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Do not render `BundlePrompt` in store builds.
|
|
72
|
-
|
|
73
|
-
## Accessing a running Metro packager
|
|
74
|
-
|
|
75
|
-
Same idea as upstream: expose your local Metro packager via a tunnel (e.g. `ngrok http 8081`) and call `BundleLoader.load(<https tunnel URL>)`. Required Metro query params:
|
|
76
|
-
|
|
77
|
-
- `dev`: `true` or `false` matching how the binary was built
|
|
78
|
-
- `excludeSource`: `true`
|
|
79
|
-
- `platform`: `ios` or `android` matching the host
|
|
80
|
-
|
|
81
|
-
Example: `https://example.ngrok.io/index.bundle?dev=false&platform=ios&excludeSource=true`
|
|
53
|
+
> The library exposes **only** the verified path. There is no unverified `load()`
|
|
54
|
+
> API: loading a remote bundle without native SHA-256 verification is an
|
|
55
|
+
> unauthenticated remote-code-execution primitive, so it was removed.
|
|
82
56
|
|
|
83
57
|
## Platform support
|
|
84
58
|
|
|
85
59
|
| Capability | iOS | Android |
|
|
86
60
|
| --------------------------- | --- | ------- |
|
|
87
|
-
| `load(url)` | ✅ | ✅ |
|
|
88
61
|
| `loadVerified(url, sha256)` | ✅ | ✅ |
|
|
89
62
|
| `runningMode()` | ✅ | ✅ |
|
|
90
63
|
|
|
91
64
|
### How bundle loading works
|
|
92
65
|
|
|
93
|
-
**iOS** downloads and verifies the bundle natively via `NSURLSession` + `CommonCrypto CC_SHA256`, writes
|
|
66
|
+
**iOS** downloads and verifies the bundle natively via `NSURLSession` + `CommonCrypto CC_SHA256`, then writes the verified bytes to `NSTemporaryDirectory()` with `NSDataWritingAtomic | NSDataWritingFileProtectionComplete` (nothing is written before verification). It stores that file URL in `NSUserDefaults` under `RNBundleLoaderPendingURLKey` and calls `[bridge reload]`; the host app's `loadSourceForBridge:` reads the pending URL and loads from it, so the bridge's own `bundleURL` — and therefore `SourceCode.scriptURL` — is never mutated, keeping asset resolution correct. This is an in-process reload; under ARC the old bridge (and its Hermes runtime) is freed before the new one allocates, so there is no double-memory peak.
|
|
94
67
|
|
|
95
68
|
**Android** uses a process restart instead of an in-process bridge swap. The reason: Android's ART garbage collector is non-deterministic. When a new React context is created alongside an existing one, ART does not guarantee the old Hermes runtime's native heap is freed before the new runtime allocates. On real-world bundle sizes (~50 MB of Hermes bytecode) this causes OOM. The process restart avoids the problem entirely by ensuring only one runtime is ever live.
|
|
96
69
|
|
|
97
70
|
After download and hash verification, the module:
|
|
98
71
|
|
|
99
|
-
1.
|
|
72
|
+
1. Downloads to a temp file, verifies the SHA-256, then **atomically promotes** it to `Context.getCacheDir()/verified-bundle.jsbundle` — the canonical path never holds unverified or partial bytes. On a hash mismatch or download error the temp file is deleted and the current bundle is left untouched.
|
|
100
73
|
2. Sets a one-shot flag in `SharedPreferences` (`"BundleLoader"` / `"pending_remote_bundle"`), using a synchronous `commit()` so the flag survives the imminent process kill.
|
|
101
74
|
3. Restarts the process via `startActivity` + `Process.killProcess`.
|
|
102
75
|
|
package/SECURITY.md
CHANGED
|
@@ -16,8 +16,9 @@ This library exists to load and execute a remote JavaScript bundle inside the ho
|
|
|
16
16
|
| Surface | Upstream `0.1.0` | This fork |
|
|
17
17
|
| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
18
|
| Bundle integrity | None — bridge fetches whatever the URL serves | `loadVerified(url, sha256)` downloads bytes natively (iOS: `NSURLSession`, Android: `HttpURLConnection`), hashes with platform crypto (iOS: `CommonCrypto CC_SHA256`, Android: `MessageDigest SHA-256`), compares in constant-time, writes to app-private storage, and reloads the bridge from the local file — closing the TOCTOU window between fetch and load |
|
|
19
|
-
| `BundlePrompt`
|
|
20
|
-
| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary;
|
|
19
|
+
| `BundlePrompt` component | URL-typing UI wired to the unverified `load()` | Removed entirely with the unverified path |
|
|
20
|
+
| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; the native `loadVerified` implementation re-checks before touching the network |
|
|
21
|
+
| Unverified `load()` path | `load(url)` fetches and reloads any URL, no integrity | Removed — only `loadVerified(url, sha256)` remains; the unverified native methods, JS export, and `BundlePrompt` UI are gone |
|
|
21
22
|
| Verified bundle on-disk protection (iOS) | n/a | Written with `NSDataWritingFileProtectionComplete` |
|
|
22
23
|
| Lockfile | Not shipped | `yarn.lock` committed; `.yarnrc` enforces `--frozen-lockfile` |
|
|
23
24
|
| Dependency version pinning | Carets (`^`) | All direct deps pinned to exact versions; `.npmrc` `save-exact=true` |
|
|
@@ -31,10 +32,16 @@ This library exists to load and execute a remote JavaScript bundle inside the ho
|
|
|
31
32
|
| `example/public/ios.min.js` (700kB blob) | Committed; served from jsdelivr to any `BundlePrompt` | Removed along with the rest of `example/` |
|
|
32
33
|
| CircleCI / Node 10 build container | `.circleci/config.yml` shipped | Removed |
|
|
33
34
|
|
|
35
|
+
## Latest hardening
|
|
36
|
+
|
|
37
|
+
- **Removed the unverified `load()` path** — the `load(url)` native methods (iOS + Android), the JS `load` export, and the `BundlePrompt` UI. Loading a remote bundle without native SHA-256 verification is an unauthenticated RCE primitive; only `loadVerified` remains.
|
|
38
|
+
- **Android verifies before install.** The download streams to a temp file; the verified bytes are atomically promoted (same-directory rename) to the canonical path only after the hash matches, and the temp is deleted on mismatch or download error — the canonical path never holds unverified or partial content.
|
|
39
|
+
- **iOS bundle-size cap (64 MB)**, matching Android's, rejects oversized responses before they are hashed, written, or loaded.
|
|
40
|
+
|
|
34
41
|
## Accepted residual risks
|
|
35
42
|
|
|
36
|
-
- **The
|
|
37
|
-
- **
|
|
43
|
+
- **The verified bundle is handed to the host app to load, not installed via a private RN API.** iOS writes the verified file URL to `NSUserDefaults` (`RNBundleLoaderPendingURLKey`), which the host app's `loadSourceForBridge:` override reads on reload; Android sets a one-shot `SharedPreferences` flag and restarts the process so the host app's `getJSBundleFile()` serves the file. The library depends on the host app implementing that read side (see the integration notes); if the host omits it the swap silently no-ops rather than loading unverified code. The library deliberately does **not** reach into non-public RN internals to force the swap.
|
|
44
|
+
- **Session scoping is host-driven.** The remote bundle is active for one session; the host app clears the pending URL / active flag on cold start. The library cannot do this itself because it is not in the app's cold-start entry point (it only runs once RN is up).
|
|
38
45
|
- **Hash verification runs in native code, not JS.** `loadVerifiedFromUrl` uses `CommonCrypto CC_SHA256` (iOS) and `MessageDigest SHA-256` (Android) with a constant-time XOR comparison loop in native code. This avoids a Hermes `RangeError: Maximum regex stack depth reached` that the previous JS-side `response.arrayBuffer()` path hit on bundles ≥ ~70 MB. The trade-off is that the integrity contract is no longer auditable as TypeScript.
|
|
39
46
|
- **`timingSafeEqual` is an inlined XOR loop in native code.** Both `ios/BundleLoader.m` and `android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java` XOR all byte pairs into an accumulator and reject the bundle if the accumulator is non-zero.
|
|
40
47
|
|
|
@@ -3,10 +3,10 @@ package com.reactnativebundleloader;
|
|
|
3
3
|
import android.content.Context;
|
|
4
4
|
import android.content.Intent;
|
|
5
5
|
import android.content.SharedPreferences;
|
|
6
|
-
import android.util.Log;
|
|
7
6
|
|
|
8
7
|
import androidx.annotation.NonNull;
|
|
9
8
|
|
|
9
|
+
import com.facebook.react.ReactApplication;
|
|
10
10
|
import com.facebook.react.bridge.Promise;
|
|
11
11
|
import com.facebook.react.bridge.ReactApplicationContext;
|
|
12
12
|
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
@@ -23,13 +23,16 @@ import java.security.NoSuchAlgorithmException;
|
|
|
23
23
|
|
|
24
24
|
public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
// These values are referenced by string literals in the host app's MainApplication.
|
|
28
|
-
// Do not rename without updating the host app integration accordingly.
|
|
26
|
+
// Host app references these as string literals (library is debugImplementation only).
|
|
29
27
|
static final String BUNDLE_FILENAME = "verified-bundle.jsbundle";
|
|
28
|
+
// Bytes are downloaded here first and only promoted to BUNDLE_FILENAME after the
|
|
29
|
+
// hash matches, so the canonical path never holds unverified/partial content.
|
|
30
|
+
static final String BUNDLE_TMP_FILENAME = "verified-bundle.jsbundle.tmp";
|
|
30
31
|
static final String PREFS_NAME = "BundleLoader";
|
|
31
32
|
static final String PREFS_PENDING_KEY = "pending_remote_bundle";
|
|
32
33
|
static final String PREFS_ACTIVE_KEY = "active_remote_bundle";
|
|
34
|
+
// Metro/APK source URL captured at load time for correct asset resolution after restart.
|
|
35
|
+
static final String PREFS_METRO_SOURCE_URL_KEY = "metro_source_url";
|
|
33
36
|
|
|
34
37
|
private static final int CONNECT_TIMEOUT_MS = 30_000;
|
|
35
38
|
private static final int READ_TIMEOUT_MS = 30_000;
|
|
@@ -45,36 +48,6 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
45
48
|
return "BundleLoader";
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
@ReactMethod
|
|
49
|
-
public void load(final String url) {
|
|
50
|
-
if (!isHttps(url)) {
|
|
51
|
-
Log.e(TAG, "Bundle URL must use the https scheme");
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
new Thread(new Runnable() {
|
|
55
|
-
@Override
|
|
56
|
-
public void run() {
|
|
57
|
-
try {
|
|
58
|
-
File targetFile = new File(
|
|
59
|
-
getReactApplicationContext().getCacheDir(),
|
|
60
|
-
BUNDLE_FILENAME
|
|
61
|
-
);
|
|
62
|
-
downloadToCache(
|
|
63
|
-
url,
|
|
64
|
-
targetFile,
|
|
65
|
-
CONNECT_TIMEOUT_MS,
|
|
66
|
-
READ_TIMEOUT_MS,
|
|
67
|
-
MAX_BUNDLE_BYTES
|
|
68
|
-
);
|
|
69
|
-
setPendingFlag();
|
|
70
|
-
restartApp();
|
|
71
|
-
} catch (Exception e) {
|
|
72
|
-
Log.e(TAG, "load(" + url + ") failed", e);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}, "BundleLoader-load").start();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
51
|
@ReactMethod
|
|
79
52
|
public void loadVerifiedFromUrl(final String url, final String expectedSha256, final Promise promise) {
|
|
80
53
|
if (!isHttps(url)) {
|
|
@@ -93,19 +66,22 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
93
66
|
new Thread(new Runnable() {
|
|
94
67
|
@Override
|
|
95
68
|
public void run() {
|
|
69
|
+
File cacheDir = getReactApplicationContext().getCacheDir();
|
|
70
|
+
File targetFile = new File(cacheDir, BUNDLE_FILENAME);
|
|
71
|
+
File tmpFile = new File(cacheDir, BUNDLE_TMP_FILENAME);
|
|
72
|
+
// Never write to the canonical path before verifying: download to a temp
|
|
73
|
+
// file, then promote it atomically only after the hash matches. Clear any
|
|
74
|
+
// stale temp left by a previously interrupted download.
|
|
75
|
+
tmpFile.delete();
|
|
96
76
|
try {
|
|
97
|
-
File targetFile = new File(
|
|
98
|
-
getReactApplicationContext().getCacheDir(),
|
|
99
|
-
BUNDLE_FILENAME
|
|
100
|
-
);
|
|
101
77
|
byte[] actualDigest = downloadAndHashToCache(
|
|
102
78
|
url,
|
|
103
|
-
|
|
79
|
+
tmpFile,
|
|
104
80
|
CONNECT_TIMEOUT_MS,
|
|
105
81
|
READ_TIMEOUT_MS,
|
|
106
82
|
MAX_BUNDLE_BYTES
|
|
107
83
|
);
|
|
108
|
-
if (!
|
|
84
|
+
if (!verifyAndInstall(tmpFile, targetFile, actualDigest, expectedDigest)) {
|
|
109
85
|
promise.reject("E_HASH_MISMATCH", "Bundle hash mismatch — refusing to load");
|
|
110
86
|
return;
|
|
111
87
|
}
|
|
@@ -114,6 +90,8 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
114
90
|
setPendingFlag();
|
|
115
91
|
restartApp();
|
|
116
92
|
} catch (Exception e) {
|
|
93
|
+
// Never leave a partial/unverified temp bundle on disk.
|
|
94
|
+
tmpFile.delete();
|
|
117
95
|
promise.reject("E_LOAD_FAILED", e.getMessage(), e);
|
|
118
96
|
}
|
|
119
97
|
}
|
|
@@ -128,21 +106,35 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
128
106
|
}
|
|
129
107
|
|
|
130
108
|
private void setPendingFlag() {
|
|
131
|
-
|
|
132
|
-
// before killProcess() terminates the process.
|
|
133
|
-
getReactApplicationContext()
|
|
109
|
+
SharedPreferences.Editor editor = getReactApplicationContext()
|
|
134
110
|
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
135
111
|
.edit()
|
|
136
|
-
.putBoolean(PREFS_PENDING_KEY, true)
|
|
137
|
-
|
|
112
|
+
.putBoolean(PREFS_PENDING_KEY, true);
|
|
113
|
+
|
|
114
|
+
// Store scriptURL for asset resolution after restart: Metro URL if available,
|
|
115
|
+
// asset:/// otherwise (genesis/release — assets served from APK).
|
|
116
|
+
String sourceUrl = getMetroSourceUrl();
|
|
117
|
+
editor.putString(PREFS_METRO_SOURCE_URL_KEY,
|
|
118
|
+
sourceUrl != null ? sourceUrl : "asset:///index.android.bundle");
|
|
119
|
+
|
|
120
|
+
// commit() not apply() — write must reach disk before killProcess().
|
|
121
|
+
editor.commit();
|
|
138
122
|
}
|
|
139
123
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
124
|
+
private String getMetroSourceUrl() {
|
|
125
|
+
try {
|
|
126
|
+
ReactApplication app = (ReactApplication) getReactApplicationContext().getApplicationContext();
|
|
127
|
+
String url = app.getReactNativeHost()
|
|
128
|
+
.getReactInstanceManager()
|
|
129
|
+
.getDevSupportManager()
|
|
130
|
+
.getSourceUrl();
|
|
131
|
+
return (url != null && !url.isEmpty()) ? url : null;
|
|
132
|
+
} catch (Exception e) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Kills and relaunches the process. Avoids running two Hermes runtimes simultaneously. */
|
|
146
138
|
private void restartApp() {
|
|
147
139
|
Context context = getReactApplicationContext();
|
|
148
140
|
Intent intent = context.getPackageManager()
|
|
@@ -154,20 +146,12 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
154
146
|
android.os.Process.killProcess(android.os.Process.myPid());
|
|
155
147
|
}
|
|
156
148
|
|
|
157
|
-
/**
|
|
158
|
-
* Returns true iff the URL is non-null and uses the https scheme.
|
|
159
|
-
* Extracted as a static helper so it can be unit-tested without the threading
|
|
160
|
-
* machinery in {@link #load(String)}.
|
|
161
|
-
*/
|
|
149
|
+
/** Package-private for testing. */
|
|
162
150
|
static boolean isHttps(String url) {
|
|
163
151
|
return url != null && url.startsWith("https://");
|
|
164
152
|
}
|
|
165
153
|
|
|
166
|
-
/**
|
|
167
|
-
* Parses a 64-character lowercase hex string into a 32-byte SHA-256 digest.
|
|
168
|
-
* Throws {@link IllegalArgumentException} on invalid input so callers can
|
|
169
|
-
* reject the promise before touching the network.
|
|
170
|
-
*/
|
|
154
|
+
/** Parses a 64-char hex string into a 32-byte digest. Package-private for testing. */
|
|
171
155
|
static byte[] parseHexSha256(String hex) {
|
|
172
156
|
if (hex == null || hex.length() != 64) {
|
|
173
157
|
throw new IllegalArgumentException("Expected SHA-256 must be a 64-character hex string");
|
|
@@ -184,11 +168,7 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
184
168
|
return out;
|
|
185
169
|
}
|
|
186
170
|
|
|
187
|
-
/**
|
|
188
|
-
* Constant-time byte array comparison: XORs all pairs into an accumulator
|
|
189
|
-
* and returns true iff the accumulator is zero. Both arrays must be the
|
|
190
|
-
* same length; returns false immediately if they differ.
|
|
191
|
-
*/
|
|
171
|
+
/** Constant-time equality check to prevent timing attacks. Package-private for testing. */
|
|
192
172
|
static boolean timingSafeEquals(byte[] a, byte[] b) {
|
|
193
173
|
if (a.length != b.length) return false;
|
|
194
174
|
int diff = 0;
|
|
@@ -199,14 +179,34 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
199
179
|
}
|
|
200
180
|
|
|
201
181
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
182
|
+
* Constant-time compares {@code actualDigest} to {@code expectedDigest}. On match, atomically
|
|
183
|
+
* promotes {@code tmpFile} onto {@code targetFile} (same-directory rename) and returns true —
|
|
184
|
+
* so {@code targetFile} only ever holds verified bytes. On mismatch, deletes {@code tmpFile}
|
|
185
|
+
* and returns false. On a promotion failure, deletes {@code tmpFile} and throws. The temp file
|
|
186
|
+
* is never left behind. Package-private for testing.
|
|
187
|
+
*/
|
|
188
|
+
static boolean verifyAndInstall(
|
|
189
|
+
File tmpFile,
|
|
190
|
+
File targetFile,
|
|
191
|
+
byte[] actualDigest,
|
|
192
|
+
byte[] expectedDigest
|
|
193
|
+
) throws IOException {
|
|
194
|
+
if (!timingSafeEquals(actualDigest, expectedDigest)) {
|
|
195
|
+
tmpFile.delete();
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
// Same-directory rename is atomic on the app's (POSIX) filesystem and replaces any
|
|
199
|
+
// existing verified bundle in place, so the canonical path is never partially written.
|
|
200
|
+
if (!tmpFile.renameTo(targetFile)) {
|
|
201
|
+
tmpFile.delete();
|
|
202
|
+
throw new IOException("Failed to promote verified bundle to " + targetFile.getName());
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Downloads into {@code targetFile} and returns its SHA-256 digest.
|
|
209
|
+
* No redirects; non-200 throws; body capped at {@code maxBytes}. Package-private for testing.
|
|
210
210
|
*/
|
|
211
211
|
static byte[] downloadAndHashToCache(
|
|
212
212
|
String urlString,
|
|
@@ -251,52 +251,4 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
|
|
|
251
251
|
conn.disconnect();
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Downloads {@code urlString} into {@code targetFile} using HttpURLConnection.
|
|
257
|
-
* Redirects are not followed and non-200 responses throw IOException with the
|
|
258
|
-
* status code in the message. The download is hard-capped at {@code maxBytes};
|
|
259
|
-
* once the cap is exceeded the read loop aborts with IOException.
|
|
260
|
-
* <p>
|
|
261
|
-
* Package-private and static so the JVM unit tests can drive it against a
|
|
262
|
-
* MockWebServer without spinning up a ReactApplicationContext.
|
|
263
|
-
*/
|
|
264
|
-
static File downloadToCache(
|
|
265
|
-
String urlString,
|
|
266
|
-
File targetFile,
|
|
267
|
-
int connectTimeoutMs,
|
|
268
|
-
int readTimeoutMs,
|
|
269
|
-
long maxBytes
|
|
270
|
-
) throws IOException {
|
|
271
|
-
URL url = new URL(urlString);
|
|
272
|
-
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
|
273
|
-
conn.setConnectTimeout(connectTimeoutMs);
|
|
274
|
-
conn.setReadTimeout(readTimeoutMs);
|
|
275
|
-
// Disallow follow-redirects so an HTTPS URL cannot transparently downgrade to HTTP.
|
|
276
|
-
conn.setInstanceFollowRedirects(false);
|
|
277
|
-
try {
|
|
278
|
-
int code = conn.getResponseCode();
|
|
279
|
-
if (code != HttpURLConnection.HTTP_OK) {
|
|
280
|
-
throw new IOException("Bundle fetch failed: HTTP " + code);
|
|
281
|
-
}
|
|
282
|
-
long total = 0;
|
|
283
|
-
try (InputStream in = conn.getInputStream();
|
|
284
|
-
FileOutputStream out = new FileOutputStream(targetFile)) {
|
|
285
|
-
byte[] buf = new byte[8192];
|
|
286
|
-
int n;
|
|
287
|
-
while ((n = in.read(buf)) != -1) {
|
|
288
|
-
total += n;
|
|
289
|
-
if (total > maxBytes) {
|
|
290
|
-
throw new IOException(
|
|
291
|
-
"Bundle exceeds " + maxBytes + " bytes"
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
out.write(buf, 0, n);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return targetFile;
|
|
298
|
-
} finally {
|
|
299
|
-
conn.disconnect();
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
254
|
}
|
package/ios/BundleLoader.h
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#import <React/RCTBridgeModule.h>
|
|
2
2
|
#import <React/RCTRootView.h>
|
|
3
3
|
|
|
4
|
+
// NSUserDefaults key storing the pending remote bundle URL for loadSourceForBridge:.
|
|
5
|
+
extern NSString * const RNBundleLoaderPendingURLKey;
|
|
6
|
+
|
|
4
7
|
@interface BundleLoader : NSObject <RCTBridgeModule>
|
|
5
8
|
|
|
6
9
|
@end
|
package/ios/BundleLoader.m
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
#import "BundleLoader.h"
|
|
2
2
|
#import <CommonCrypto/CommonDigest.h>
|
|
3
3
|
|
|
4
|
+
NSString * const RNBundleLoaderPendingURLKey = @"RNBundleLoaderPendingURL";
|
|
5
|
+
|
|
6
|
+
// Defensive cap on the downloaded bundle size (parity with the Android module's
|
|
7
|
+
// MAX_BUNDLE_BYTES). Real bundles are ~50 MB; reject anything absurd before it is
|
|
8
|
+
// hashed, written, or loaded.
|
|
9
|
+
static const NSUInteger RNBundleLoaderMaxBundleBytes = 64UL * 1024UL * 1024UL;
|
|
10
|
+
|
|
4
11
|
@implementation BundleLoader
|
|
5
12
|
|
|
6
13
|
@synthesize bridge = _bridge;
|
|
@@ -9,26 +16,16 @@ RCT_EXPORT_MODULE()
|
|
|
9
16
|
|
|
10
17
|
- (void)setBundleURLAndReload:(NSURL *)url
|
|
11
18
|
{
|
|
12
|
-
[
|
|
19
|
+
[[NSUserDefaults standardUserDefaults] setURL:url forKey:RNBundleLoaderPendingURLKey];
|
|
20
|
+
[[NSUserDefaults standardUserDefaults] synchronize];
|
|
13
21
|
[_bridge reload];
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
RCT_EXPORT_METHOD(runningMode:(RCTPromiseResolveBlock)resolve
|
|
17
25
|
rejecter:(RCTPromiseRejectBlock)reject)
|
|
18
26
|
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
resolve(isRemote ? @"REMOTE" : @"LOCAL");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
RCT_EXPORT_METHOD(load:(NSURL *)url)
|
|
25
|
-
{
|
|
26
|
-
if (![[url scheme] isEqualToString:@"https"]) {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
dispatch_async(dispatch_get_main_queue(), ^{
|
|
30
|
-
[self setBundleURLAndReload:url];
|
|
31
|
-
});
|
|
27
|
+
NSURL *pending = [[NSUserDefaults standardUserDefaults] URLForKey:RNBundleLoaderPendingURLKey];
|
|
28
|
+
resolve(pending ? @"REMOTE" : @"LOCAL");
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
// Downloads the bundle at `urlString`, verifies its SHA-256 digest against
|
|
@@ -84,6 +81,14 @@ RCT_EXPORT_METHOD(loadVerifiedFromUrl:(NSString *)urlString
|
|
|
84
81
|
return;
|
|
85
82
|
}
|
|
86
83
|
|
|
84
|
+
if (data.length > RNBundleLoaderMaxBundleBytes) {
|
|
85
|
+
reject(@"E_TOO_LARGE",
|
|
86
|
+
[NSString stringWithFormat:@"Bundle exceeds %lu bytes",
|
|
87
|
+
(unsigned long)RNBundleLoaderMaxBundleBytes],
|
|
88
|
+
nil);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
87
92
|
// Compute SHA-256 of the downloaded bytes.
|
|
88
93
|
uint8_t actualDigest[CC_SHA256_DIGEST_LENGTH];
|
|
89
94
|
CC_SHA256(data.bytes, (CC_LONG)data.length, actualDigest);
|
package/lib/commonjs/index.js
CHANGED
|
@@ -4,17 +4,10 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.loadVerified = loadVerified;
|
|
7
|
-
exports.BundlePrompt = BundlePrompt;
|
|
8
7
|
exports.default = void 0;
|
|
9
8
|
|
|
10
|
-
var _react = _interopRequireWildcard(require("react"));
|
|
11
|
-
|
|
12
9
|
var _reactNative = require("react-native");
|
|
13
10
|
|
|
14
|
-
function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; }
|
|
15
|
-
|
|
16
|
-
function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
|
|
17
|
-
|
|
18
11
|
/** @format */
|
|
19
12
|
function getNative() {
|
|
20
13
|
const m = _reactNative.NativeModules.BundleLoader;
|
|
@@ -52,81 +45,17 @@ async function loadVerified(url, expectedSha256Hex) {
|
|
|
52
45
|
await native.loadVerifiedFromUrl(url, expectedSha256Hex);
|
|
53
46
|
}
|
|
54
47
|
|
|
55
|
-
function loadUnverified(url) {
|
|
56
|
-
assertSafeUrl(url);
|
|
57
|
-
getNative().load(url);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
48
|
async function runningMode() {
|
|
61
49
|
return getNative().runningMode();
|
|
62
|
-
}
|
|
50
|
+
} // Only the verified load path is exposed. The unverified `load()` path was
|
|
51
|
+
// removed (security): loading a remote bundle without native SHA-256
|
|
52
|
+
// verification is a remote-code-execution primitive with no integrity check.
|
|
53
|
+
|
|
63
54
|
|
|
64
55
|
const BundleLoader = {
|
|
65
|
-
load: loadUnverified,
|
|
66
56
|
loadVerified,
|
|
67
57
|
runningMode
|
|
68
58
|
};
|
|
69
59
|
var _default = BundleLoader;
|
|
70
60
|
exports.default = _default;
|
|
71
|
-
|
|
72
|
-
const styles = _reactNative.StyleSheet.create({
|
|
73
|
-
container: {
|
|
74
|
-
marginTop: 48,
|
|
75
|
-
padding: 16,
|
|
76
|
-
flex: 1
|
|
77
|
-
},
|
|
78
|
-
input: {
|
|
79
|
-
height: 48,
|
|
80
|
-
marginTop: 8,
|
|
81
|
-
paddingHorizontal: 8,
|
|
82
|
-
borderColor: 'gray',
|
|
83
|
-
borderRadius: 4,
|
|
84
|
-
borderWidth: 1
|
|
85
|
-
},
|
|
86
|
-
button: {
|
|
87
|
-
backgroundColor: '#007AFF',
|
|
88
|
-
marginTop: 16,
|
|
89
|
-
height: 48,
|
|
90
|
-
justifyContent: 'center'
|
|
91
|
-
},
|
|
92
|
-
buttonText: {
|
|
93
|
-
color: 'white',
|
|
94
|
-
alignSelf: 'center',
|
|
95
|
-
fontSize: 18,
|
|
96
|
-
alignContent: 'center'
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
function BundlePrompt() {
|
|
101
|
-
const [url, setUrl] = (0, _react.useState)('');
|
|
102
|
-
const reload = (0, _react.useCallback)(() => {
|
|
103
|
-
if (!url) {
|
|
104
|
-
_reactNative.Alert.alert('Oops…', 'You need to provide a URL');
|
|
105
|
-
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
loadUnverified(url);
|
|
111
|
-
} catch (e) {
|
|
112
|
-
_reactNative.Alert.alert('Invalid URL', e.message);
|
|
113
|
-
}
|
|
114
|
-
}, [url]);
|
|
115
|
-
return /*#__PURE__*/_react.default.createElement(_reactNative.Modal, null, /*#__PURE__*/_react.default.createElement(_reactNative.View, {
|
|
116
|
-
style: styles.container
|
|
117
|
-
}, /*#__PURE__*/_react.default.createElement(_reactNative.TextInput, {
|
|
118
|
-
keyboardType: "url",
|
|
119
|
-
onChange: e => setUrl(e.nativeEvent.text.trim()),
|
|
120
|
-
style: styles.input,
|
|
121
|
-
clearButtonMode: "always",
|
|
122
|
-
autoFocus: true,
|
|
123
|
-
placeholder: "https://\u2026"
|
|
124
|
-
}), /*#__PURE__*/_react.default.createElement(_reactNative.TouchableOpacity, {
|
|
125
|
-
style: styles.button,
|
|
126
|
-
onPress: reload,
|
|
127
|
-
accessibilityLabel: "Reload entire app"
|
|
128
|
-
}, /*#__PURE__*/_react.default.createElement(_reactNative.Text, {
|
|
129
|
-
style: styles.buttonText
|
|
130
|
-
}, "Reload"))));
|
|
131
|
-
}
|
|
132
61
|
//# sourceMappingURL=index.js.map
|
package/lib/module/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import
|
|
3
|
-
import { Alert, Modal, NativeModules, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
|
2
|
+
import { NativeModules } from 'react-native';
|
|
4
3
|
|
|
5
4
|
function getNative() {
|
|
6
5
|
const m = NativeModules.BundleLoader;
|
|
@@ -38,77 +37,16 @@ export async function loadVerified(url, expectedSha256Hex) {
|
|
|
38
37
|
await native.loadVerifiedFromUrl(url, expectedSha256Hex);
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
function loadUnverified(url) {
|
|
42
|
-
assertSafeUrl(url);
|
|
43
|
-
getNative().load(url);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
async function runningMode() {
|
|
47
41
|
return getNative().runningMode();
|
|
48
|
-
}
|
|
42
|
+
} // Only the verified load path is exposed. The unverified `load()` path was
|
|
43
|
+
// removed (security): loading a remote bundle without native SHA-256
|
|
44
|
+
// verification is a remote-code-execution primitive with no integrity check.
|
|
45
|
+
|
|
49
46
|
|
|
50
47
|
const BundleLoader = {
|
|
51
|
-
load: loadUnverified,
|
|
52
48
|
loadVerified,
|
|
53
49
|
runningMode
|
|
54
50
|
};
|
|
55
51
|
export default BundleLoader;
|
|
56
|
-
const styles = StyleSheet.create({
|
|
57
|
-
container: {
|
|
58
|
-
marginTop: 48,
|
|
59
|
-
padding: 16,
|
|
60
|
-
flex: 1
|
|
61
|
-
},
|
|
62
|
-
input: {
|
|
63
|
-
height: 48,
|
|
64
|
-
marginTop: 8,
|
|
65
|
-
paddingHorizontal: 8,
|
|
66
|
-
borderColor: 'gray',
|
|
67
|
-
borderRadius: 4,
|
|
68
|
-
borderWidth: 1
|
|
69
|
-
},
|
|
70
|
-
button: {
|
|
71
|
-
backgroundColor: '#007AFF',
|
|
72
|
-
marginTop: 16,
|
|
73
|
-
height: 48,
|
|
74
|
-
justifyContent: 'center'
|
|
75
|
-
},
|
|
76
|
-
buttonText: {
|
|
77
|
-
color: 'white',
|
|
78
|
-
alignSelf: 'center',
|
|
79
|
-
fontSize: 18,
|
|
80
|
-
alignContent: 'center'
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
export function BundlePrompt() {
|
|
84
|
-
const [url, setUrl] = useState('');
|
|
85
|
-
const reload = useCallback(() => {
|
|
86
|
-
if (!url) {
|
|
87
|
-
Alert.alert('Oops…', 'You need to provide a URL');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
loadUnverified(url);
|
|
93
|
-
} catch (e) {
|
|
94
|
-
Alert.alert('Invalid URL', e.message);
|
|
95
|
-
}
|
|
96
|
-
}, [url]);
|
|
97
|
-
return /*#__PURE__*/React.createElement(Modal, null, /*#__PURE__*/React.createElement(View, {
|
|
98
|
-
style: styles.container
|
|
99
|
-
}, /*#__PURE__*/React.createElement(TextInput, {
|
|
100
|
-
keyboardType: "url",
|
|
101
|
-
onChange: e => setUrl(e.nativeEvent.text.trim()),
|
|
102
|
-
style: styles.input,
|
|
103
|
-
clearButtonMode: "always",
|
|
104
|
-
autoFocus: true,
|
|
105
|
-
placeholder: "https://\u2026"
|
|
106
|
-
}), /*#__PURE__*/React.createElement(TouchableOpacity, {
|
|
107
|
-
style: styles.button,
|
|
108
|
-
onPress: reload,
|
|
109
|
-
accessibilityLabel: "Reload entire app"
|
|
110
|
-
}, /*#__PURE__*/React.createElement(Text, {
|
|
111
|
-
style: styles.buttonText
|
|
112
|
-
}, "Reload"))));
|
|
113
|
-
}
|
|
114
52
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
/// <reference types="react" />
|
|
2
1
|
export declare type RunningMode = 'LOCAL' | 'REMOTE';
|
|
3
2
|
export declare function loadVerified(url: string, expectedSha256Hex: string): Promise<void>;
|
|
4
|
-
declare function loadUnverified(url: string): void;
|
|
5
3
|
declare function runningMode(): Promise<RunningMode>;
|
|
6
4
|
declare const BundleLoader: {
|
|
7
|
-
load: typeof loadUnverified;
|
|
8
5
|
loadVerified: typeof loadVerified;
|
|
9
6
|
runningMode: typeof runningMode;
|
|
10
7
|
};
|
|
11
8
|
export default BundleLoader;
|
|
12
|
-
export declare function BundlePrompt(): JSX.Element;
|
package/package.json
CHANGED
package/src/index.tsx
CHANGED
|
@@ -1,22 +1,9 @@
|
|
|
1
1
|
/** @format */
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
Alert,
|
|
5
|
-
Modal,
|
|
6
|
-
NativeModules,
|
|
7
|
-
NativeSyntheticEvent,
|
|
8
|
-
StyleSheet,
|
|
9
|
-
Text,
|
|
10
|
-
TextInput,
|
|
11
|
-
TextInputChangeEventData,
|
|
12
|
-
TouchableOpacity,
|
|
13
|
-
View,
|
|
14
|
-
} from 'react-native';
|
|
2
|
+
import { NativeModules } from 'react-native';
|
|
15
3
|
|
|
16
4
|
export type RunningMode = 'LOCAL' | 'REMOTE';
|
|
17
5
|
|
|
18
6
|
type NativeBundleLoader = {
|
|
19
|
-
load(url: string): void;
|
|
20
7
|
loadVerifiedFromUrl?(url: string, sha256: string): Promise<void>;
|
|
21
8
|
runningMode(): Promise<RunningMode>;
|
|
22
9
|
};
|
|
@@ -66,83 +53,16 @@ export async function loadVerified(
|
|
|
66
53
|
await native.loadVerifiedFromUrl(url, expectedSha256Hex);
|
|
67
54
|
}
|
|
68
55
|
|
|
69
|
-
function loadUnverified(url: string): void {
|
|
70
|
-
assertSafeUrl(url);
|
|
71
|
-
getNative().load(url);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
56
|
async function runningMode(): Promise<RunningMode> {
|
|
75
57
|
return getNative().runningMode();
|
|
76
58
|
}
|
|
77
59
|
|
|
60
|
+
// Only the verified load path is exposed. The unverified `load()` path was
|
|
61
|
+
// removed (security): loading a remote bundle without native SHA-256
|
|
62
|
+
// verification is a remote-code-execution primitive with no integrity check.
|
|
78
63
|
const BundleLoader = {
|
|
79
|
-
load: loadUnverified,
|
|
80
64
|
loadVerified,
|
|
81
65
|
runningMode,
|
|
82
66
|
};
|
|
83
67
|
|
|
84
68
|
export default BundleLoader;
|
|
85
|
-
|
|
86
|
-
const styles = StyleSheet.create({
|
|
87
|
-
container: { marginTop: 48, padding: 16, flex: 1 },
|
|
88
|
-
input: {
|
|
89
|
-
height: 48,
|
|
90
|
-
marginTop: 8,
|
|
91
|
-
paddingHorizontal: 8,
|
|
92
|
-
borderColor: 'gray',
|
|
93
|
-
borderRadius: 4,
|
|
94
|
-
borderWidth: 1,
|
|
95
|
-
},
|
|
96
|
-
button: {
|
|
97
|
-
backgroundColor: '#007AFF',
|
|
98
|
-
marginTop: 16,
|
|
99
|
-
height: 48,
|
|
100
|
-
justifyContent: 'center',
|
|
101
|
-
},
|
|
102
|
-
buttonText: {
|
|
103
|
-
color: 'white',
|
|
104
|
-
alignSelf: 'center',
|
|
105
|
-
fontSize: 18,
|
|
106
|
-
alignContent: 'center',
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
export function BundlePrompt() {
|
|
111
|
-
const [url, setUrl] = useState<string>('');
|
|
112
|
-
|
|
113
|
-
const reload = useCallback(() => {
|
|
114
|
-
if (!url) {
|
|
115
|
-
Alert.alert('Oops…', 'You need to provide a URL');
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
try {
|
|
119
|
-
loadUnverified(url);
|
|
120
|
-
} catch (e) {
|
|
121
|
-
Alert.alert('Invalid URL', (e as Error).message);
|
|
122
|
-
}
|
|
123
|
-
}, [url]);
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<Modal>
|
|
127
|
-
<View style={styles.container}>
|
|
128
|
-
<TextInput
|
|
129
|
-
keyboardType="url"
|
|
130
|
-
onChange={(e: NativeSyntheticEvent<TextInputChangeEventData>) =>
|
|
131
|
-
setUrl(e.nativeEvent.text.trim())
|
|
132
|
-
}
|
|
133
|
-
style={styles.input}
|
|
134
|
-
clearButtonMode="always"
|
|
135
|
-
autoFocus
|
|
136
|
-
placeholder="https://…"
|
|
137
|
-
/>
|
|
138
|
-
<TouchableOpacity
|
|
139
|
-
style={styles.button}
|
|
140
|
-
onPress={reload}
|
|
141
|
-
accessibilityLabel="Reload entire app"
|
|
142
|
-
>
|
|
143
|
-
<Text style={styles.buttonText}>Reload</Text>
|
|
144
|
-
</TouchableOpacity>
|
|
145
|
-
</View>
|
|
146
|
-
</Modal>
|
|
147
|
-
);
|
|
148
|
-
}
|