@exodus/react-native-bundle-loader 0.2.0-exodus.3 → 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,7 +3,6 @@ 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
 
@@ -24,9 +23,11 @@ import java.security.NoSuchAlgorithmException;
24
23
 
25
24
  public class BundleLoaderModule extends ReactContextBaseJavaModule {
26
25
 
27
- private static final String TAG = "BundleLoader";
28
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";
@@ -47,36 +48,6 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
47
48
  return "BundleLoader";
48
49
  }
49
50
 
50
- @ReactMethod
51
- public void load(final String url) {
52
- if (!isHttps(url)) {
53
- Log.e(TAG, "Bundle URL must use the https scheme");
54
- return;
55
- }
56
- new Thread(new Runnable() {
57
- @Override
58
- public void run() {
59
- try {
60
- File targetFile = new File(
61
- getReactApplicationContext().getCacheDir(),
62
- BUNDLE_FILENAME
63
- );
64
- downloadToCache(
65
- url,
66
- targetFile,
67
- CONNECT_TIMEOUT_MS,
68
- READ_TIMEOUT_MS,
69
- MAX_BUNDLE_BYTES
70
- );
71
- setPendingFlag();
72
- restartApp();
73
- } catch (Exception e) {
74
- Log.e(TAG, "load(" + url + ") failed", e);
75
- }
76
- }
77
- }, "BundleLoader-load").start();
78
- }
79
-
80
51
  @ReactMethod
81
52
  public void loadVerifiedFromUrl(final String url, final String expectedSha256, final Promise promise) {
82
53
  if (!isHttps(url)) {
@@ -95,19 +66,22 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
95
66
  new Thread(new Runnable() {
96
67
  @Override
97
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();
98
76
  try {
99
- File targetFile = new File(
100
- getReactApplicationContext().getCacheDir(),
101
- BUNDLE_FILENAME
102
- );
103
77
  byte[] actualDigest = downloadAndHashToCache(
104
78
  url,
105
- targetFile,
79
+ tmpFile,
106
80
  CONNECT_TIMEOUT_MS,
107
81
  READ_TIMEOUT_MS,
108
82
  MAX_BUNDLE_BYTES
109
83
  );
110
- if (!timingSafeEquals(actualDigest, expectedDigest)) {
84
+ if (!verifyAndInstall(tmpFile, targetFile, actualDigest, expectedDigest)) {
111
85
  promise.reject("E_HASH_MISMATCH", "Bundle hash mismatch — refusing to load");
112
86
  return;
113
87
  }
@@ -116,6 +90,8 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
116
90
  setPendingFlag();
117
91
  restartApp();
118
92
  } catch (Exception e) {
93
+ // Never leave a partial/unverified temp bundle on disk.
94
+ tmpFile.delete();
119
95
  promise.reject("E_LOAD_FAILED", e.getMessage(), e);
120
96
  }
121
97
  }
@@ -202,6 +178,32 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
202
178
  return diff == 0;
203
179
  }
204
180
 
181
+ /**
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
+
205
207
  /**
206
208
  * Downloads into {@code targetFile} and returns its SHA-256 digest.
207
209
  * No redirects; non-200 throws; body capped at {@code maxBytes}. Package-private for testing.
@@ -249,47 +251,4 @@ public class BundleLoaderModule extends ReactContextBaseJavaModule {
249
251
  conn.disconnect();
250
252
  }
251
253
  }
252
-
253
- /**
254
- * Downloads into {@code targetFile}. No redirects; non-200 throws; body capped at
255
- * {@code maxBytes}. Package-private for testing.
256
- */
257
- static File downloadToCache(
258
- String urlString,
259
- File targetFile,
260
- int connectTimeoutMs,
261
- int readTimeoutMs,
262
- long maxBytes
263
- ) throws IOException {
264
- URL url = new URL(urlString);
265
- HttpURLConnection conn = (HttpURLConnection) url.openConnection();
266
- conn.setConnectTimeout(connectTimeoutMs);
267
- conn.setReadTimeout(readTimeoutMs);
268
- // Disallow follow-redirects so an HTTPS URL cannot transparently downgrade to HTTP.
269
- conn.setInstanceFollowRedirects(false);
270
- try {
271
- int code = conn.getResponseCode();
272
- if (code != HttpURLConnection.HTTP_OK) {
273
- throw new IOException("Bundle fetch failed: HTTP " + code);
274
- }
275
- long total = 0;
276
- try (InputStream in = conn.getInputStream();
277
- FileOutputStream out = new FileOutputStream(targetFile)) {
278
- byte[] buf = new byte[8192];
279
- int n;
280
- while ((n = in.read(buf)) != -1) {
281
- total += n;
282
- if (total > maxBytes) {
283
- throw new IOException(
284
- "Bundle exceeds " + maxBytes + " bytes"
285
- );
286
- }
287
- out.write(buf, 0, n);
288
- }
289
- }
290
- return targetFile;
291
- } finally {
292
- conn.disconnect();
293
- }
294
- }
295
254
  }
@@ -3,6 +3,11 @@
3
3
 
4
4
  NSString * const RNBundleLoaderPendingURLKey = @"RNBundleLoaderPendingURL";
5
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
+
6
11
  @implementation BundleLoader
7
12
 
8
13
  @synthesize bridge = _bridge;
@@ -23,16 +28,6 @@ RCT_EXPORT_METHOD(runningMode:(RCTPromiseResolveBlock)resolve
23
28
  resolve(pending ? @"REMOTE" : @"LOCAL");
24
29
  }
25
30
 
26
- RCT_EXPORT_METHOD(load:(NSURL *)url)
27
- {
28
- if (![[url scheme] isEqualToString:@"https"]) {
29
- return;
30
- }
31
- dispatch_async(dispatch_get_main_queue(), ^{
32
- [self setBundleURLAndReload:url];
33
- });
34
- }
35
-
36
31
  // Downloads the bundle at `urlString`, verifies its SHA-256 digest against
37
32
  // `expectedHex` using a constant-time byte comparison, then writes it to the
38
33
  // sandbox temp directory and reloads the bridge — all in native code to avoid
@@ -86,6 +81,14 @@ RCT_EXPORT_METHOD(loadVerifiedFromUrl:(NSString *)urlString
86
81
  return;
87
82
  }
88
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
+
89
92
  // Compute SHA-256 of the downloaded bytes.
90
93
  uint8_t actualDigest[CC_SHA256_DIGEST_LENGTH];
91
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.3",
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
- }