@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 CHANGED
@@ -50,53 +50,26 @@ Behavior:
50
50
 
51
51
  Works on iOS and Android.
52
52
 
53
- ### Unverified loading
54
-
55
- ```ts
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 it to `NSTemporaryDirectory()` with `NSDataWritingFileProtectionComplete`, then sets the bridge's `bundleURL` via KVC (`[bridge setValue:url forKey:@"bundleURL"]`) and calls `[bridge reload]`. This is an in-process reload: the old bridge is torn down and a new one is created with the cached file. Because iOS uses ARC, the old bridge's memory (including the Hermes runtime) is freed immediately when the bridge reference is released, before the new runtime allocates — no double-memory peak.
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. Writes the bundle to `Context.getCacheDir()/verified-bundle.jsbundle`.
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` default URL | Hardcoded `cdn.jsdelivr.net/gh/jusbrasil/...` (deleted) | Empty operator must type a URL |
20
- | Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; both native `load` implementations re-check before touching the network |
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 bridge `bundleURL` setter is a KVC write** on iOS (`[bridge setValue:url forKey:@"bundleURL"]`) to a non-public RN property. Behavior could change on an RN upgrade and silently no-op the loader.
37
- - **The Android bundle swap reflects on a private field.** `ReactInstanceManager.mBundleLoader` has no public setter, so we use `Field.setAccessible(true)` to install a fresh `JSBundleLoader.createFileLoader(...)` before calling `recreateReactContextInBackground()`. The field name has been stable across RN 0.62–0.74 but is not part of the public API; an RN upgrade could rename or remove it, in which case `loadVerified`/`load` will throw `NoSuchFieldException` rather than silently no-op.
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
- private static final String TAG = "BundleLoader";
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
- targetFile,
79
+ tmpFile,
104
80
  CONNECT_TIMEOUT_MS,
105
81
  READ_TIMEOUT_MS,
106
82
  MAX_BUNDLE_BYTES
107
83
  );
108
- if (!timingSafeEquals(actualDigest, expectedDigest)) {
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
- // commit() not apply() — apply() is async and the write may not reach disk
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
- .commit();
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
- * Restarts the app process. On next launch, MainApplication reads the pending
142
- * flag from SharedPreferences and loads the cached bundle instead of Metro.
143
- * A process restart avoids running both the old and new Hermes runtimes
144
- * simultaneously, which would exceed the device heap limit.
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
- * Downloads {@code urlString} into {@code targetFile}, computing SHA-256 of
203
- * the body in the same streaming pass. Returns the 32-byte digest.
204
- * <p>
205
- * Mirrors the security properties of {@link #downloadToCache}: redirects are
206
- * disabled, non-200 responses throw, and the body is capped at {@code maxBytes}.
207
- * <p>
208
- * Package-private and static so the JVM unit tests can drive it against a
209
- * MockWebServer without spinning up a ReactApplicationContext.
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
  }
@@ -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
@@ -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
- [_bridge setValue:url forKey:@"bundleURL"];
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
- NSString *scheme = [[_bridge bundleURL] scheme];
20
- BOOL isRemote = [scheme isEqualToString:@"https"];
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);
@@ -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
@@ -1,6 +1,5 @@
1
1
  /** @format */
2
- import React, { useCallback, useState } from 'react';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/react-native-bundle-loader",
3
- "version": "0.2.0-exodus.2",
3
+ "version": "0.2.0-exodus.4",
4
4
  "description": "Loads a remote React Native JS bundle.",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
package/src/index.tsx CHANGED
@@ -1,22 +1,9 @@
1
1
  /** @format */
2
- import React, { useCallback, useState } from 'react';
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
- }