Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 5 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,53 +50,26 @@ Behavior:

Works on iOS and Android.

### Unverified loading

```ts
BundleLoader.load('https://bundles.example.com/main.jsbundle');
```

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.**

The URL is required to use `https:`.

### `BundlePrompt`

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.

```tsx
import { BundlePrompt } from '@exodus/react-native-bundle-loader';
```

Do not render `BundlePrompt` in store builds.

## Accessing a running Metro packager

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:

- `dev`: `true` or `false` matching how the binary was built
- `excludeSource`: `true`
- `platform`: `ios` or `android` matching the host

Example: `https://example.ngrok.io/index.bundle?dev=false&platform=ios&excludeSource=true`
> The library exposes **only** the verified path. There is no unverified `load()`
> API: loading a remote bundle without native SHA-256 verification is an
> unauthenticated remote-code-execution primitive, so it was removed.

## Platform support

| Capability | iOS | Android |
| --------------------------- | --- | ------- |
| `load(url)` | ✅ | ✅ |
| `loadVerified(url, sha256)` | ✅ | ✅ |
| `runningMode()` | ✅ | ✅ |

### How bundle loading works

**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.
**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.

**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.

After download and hash verification, the module:

1. Writes the bundle to `Context.getCacheDir()/verified-bundle.jsbundle`.
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.
2. Sets a one-shot flag in `SharedPreferences` (`"BundleLoader"` / `"pending_remote_bundle"`), using a synchronous `commit()` so the flag survives the imminent process kill.
3. Restarts the process via `startActivity` + `Process.killProcess`.

Expand Down
15 changes: 11 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ This library exists to load and execute a remote JavaScript bundle inside the ho
| Surface | Upstream `0.1.0` | This fork |
| ------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| `BundlePrompt` default URL | Hardcoded `cdn.jsdelivr.net/gh/jusbrasil/...` (deleted) | Empty — operator must type a URL |
| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; both native `load` implementations re-check before touching the network |
| `BundlePrompt` component | URL-typing UI wired to the unverified `load()` | Removed entirely with the unverified path |
| Scheme enforcement | None — accepts `http://`, `file://`, etc. | `https://` required at the JS boundary; the native `loadVerified` implementation re-checks before touching the network |
| 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 |
| Verified bundle on-disk protection (iOS) | n/a | Written with `NSDataWritingFileProtectionComplete` |
| Lockfile | Not shipped | `yarn.lock` committed; `.yarnrc` enforces `--frozen-lockfile` |
| Dependency version pinning | Carets (`^`) | All direct deps pinned to exact versions; `.npmrc` `save-exact=true` |
Expand All @@ -31,10 +32,16 @@ This library exists to load and execute a remote JavaScript bundle inside the ho
| `example/public/ios.min.js` (700kB blob) | Committed; served from jsdelivr to any `BundlePrompt` | Removed along with the rest of `example/` |
| CircleCI / Node 10 build container | `.circleci/config.yml` shipped | Removed |

## Latest hardening

- **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.
- **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.
- **iOS bundle-size cap (64 MB)**, matching Android's, rejects oversized responses before they are hashed, written, or loaded.

## Accepted residual risks

- **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.
- **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.
- **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.
- **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).
- **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.
- **`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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.util.Log;

import androidx.annotation.NonNull;

Expand All @@ -24,9 +23,11 @@

public class BundleLoaderModule extends ReactContextBaseJavaModule {

private static final String TAG = "BundleLoader";
// Host app references these as string literals (library is debugImplementation only).
static final String BUNDLE_FILENAME = "verified-bundle.jsbundle";
// Bytes are downloaded here first and only promoted to BUNDLE_FILENAME after the
// hash matches, so the canonical path never holds unverified/partial content.
static final String BUNDLE_TMP_FILENAME = "verified-bundle.jsbundle.tmp";
static final String PREFS_NAME = "BundleLoader";
static final String PREFS_PENDING_KEY = "pending_remote_bundle";
static final String PREFS_ACTIVE_KEY = "active_remote_bundle";
Expand All @@ -47,36 +48,6 @@ public String getName() {
return "BundleLoader";
}

@ReactMethod
public void load(final String url) {
if (!isHttps(url)) {
Log.e(TAG, "Bundle URL must use the https scheme");
return;
}
new Thread(new Runnable() {
@Override
public void run() {
try {
File targetFile = new File(
getReactApplicationContext().getCacheDir(),
BUNDLE_FILENAME
);
downloadToCache(
url,
targetFile,
CONNECT_TIMEOUT_MS,
READ_TIMEOUT_MS,
MAX_BUNDLE_BYTES
);
setPendingFlag();
restartApp();
} catch (Exception e) {
Log.e(TAG, "load(" + url + ") failed", e);
}
}
}, "BundleLoader-load").start();
}

@ReactMethod
public void loadVerifiedFromUrl(final String url, final String expectedSha256, final Promise promise) {
if (!isHttps(url)) {
Expand All @@ -95,19 +66,22 @@ public void loadVerifiedFromUrl(final String url, final String expectedSha256, f
new Thread(new Runnable() {
@Override
public void run() {
File cacheDir = getReactApplicationContext().getCacheDir();
File targetFile = new File(cacheDir, BUNDLE_FILENAME);
File tmpFile = new File(cacheDir, BUNDLE_TMP_FILENAME);
// Never write to the canonical path before verifying: download to a temp
// file, then promote it atomically only after the hash matches. Clear any
// stale temp left by a previously interrupted download.
tmpFile.delete();
try {
File targetFile = new File(
getReactApplicationContext().getCacheDir(),
BUNDLE_FILENAME
);
byte[] actualDigest = downloadAndHashToCache(
url,
targetFile,
tmpFile,
CONNECT_TIMEOUT_MS,
READ_TIMEOUT_MS,
MAX_BUNDLE_BYTES
);
if (!timingSafeEquals(actualDigest, expectedDigest)) {
if (!verifyAndInstall(tmpFile, targetFile, actualDigest, expectedDigest)) {
promise.reject("E_HASH_MISMATCH", "Bundle hash mismatch — refusing to load");
return;
}
Expand All @@ -116,6 +90,8 @@ public void run() {
setPendingFlag();
restartApp();
} catch (Exception e) {
// Never leave a partial/unverified temp bundle on disk.
tmpFile.delete();
promise.reject("E_LOAD_FAILED", e.getMessage(), e);
}
}
Expand Down Expand Up @@ -202,6 +178,32 @@ static boolean timingSafeEquals(byte[] a, byte[] b) {
return diff == 0;
}

/**
* Constant-time compares {@code actualDigest} to {@code expectedDigest}. On match, atomically
* promotes {@code tmpFile} onto {@code targetFile} (same-directory rename) and returns true —
* so {@code targetFile} only ever holds verified bytes. On mismatch, deletes {@code tmpFile}
* and returns false. On a promotion failure, deletes {@code tmpFile} and throws. The temp file
* is never left behind. Package-private for testing.
*/
static boolean verifyAndInstall(
File tmpFile,
File targetFile,
byte[] actualDigest,
byte[] expectedDigest
) throws IOException {
if (!timingSafeEquals(actualDigest, expectedDigest)) {
tmpFile.delete();
return false;
}
// Same-directory rename is atomic on the app's (POSIX) filesystem and replaces any
// existing verified bundle in place, so the canonical path is never partially written.
if (!tmpFile.renameTo(targetFile)) {
tmpFile.delete();
throw new IOException("Failed to promote verified bundle to " + targetFile.getName());
}
return true;
}

/**
* Downloads into {@code targetFile} and returns its SHA-256 digest.
* No redirects; non-200 throws; body capped at {@code maxBytes}. Package-private for testing.
Expand Down Expand Up @@ -249,47 +251,4 @@ static byte[] downloadAndHashToCache(
conn.disconnect();
}
}

/**
* Downloads into {@code targetFile}. No redirects; non-200 throws; body capped at
* {@code maxBytes}. Package-private for testing.
*/
static File downloadToCache(
String urlString,
File targetFile,
int connectTimeoutMs,
int readTimeoutMs,
long maxBytes
) throws IOException {
URL url = new URL(urlString);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(connectTimeoutMs);
conn.setReadTimeout(readTimeoutMs);
// Disallow follow-redirects so an HTTPS URL cannot transparently downgrade to HTTP.
conn.setInstanceFollowRedirects(false);
try {
int code = conn.getResponseCode();
if (code != HttpURLConnection.HTTP_OK) {
throw new IOException("Bundle fetch failed: HTTP " + code);
}
long total = 0;
try (InputStream in = conn.getInputStream();
FileOutputStream out = new FileOutputStream(targetFile)) {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > maxBytes) {
throw new IOException(
"Bundle exceeds " + maxBytes + " bytes"
);
}
out.write(buf, 0, n);
}
}
return targetFile;
} finally {
conn.disconnect();
}
}
}
Loading
Loading