From 55108563d20039ab90434a74eec5af0fbdb35f8a Mon Sep 17 00:00:00 2001 From: doguhan Date: Tue, 1 Sep 2026 01:04:18 +0200 Subject: [PATCH 1/3] =?UTF-8?q?security:=20harden=20loader=20=E2=80=94=20r?= =?UTF-8?q?emove=20unverified=20load(),=20atomic=20verified=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the unverified load() path: iOS load:, Android load() + downloadToCache, the JS load/loadUnverified exports, and the BundlePrompt UI. Only loadVerified remains — loading a remote bundle without native SHA-256 verification is an unauthenticated RCE primitive. - Android: download to a temp file, verify, then atomically promote it to the canonical verified-bundle.jsbundle only after the hash matches; delete the temp on mismatch or error. The canonical path never holds unverified/partial bytes (fixes verify-after-write TOCTOU). - iOS: cap the downloaded bundle at 64 MB (parity with Android) before it is hashed, written, or loaded. - Tests: drop the removed load() cases; add VerifyAndInstallTest. Co-Authored-By: Claude Opus 4.8 --- .../BundleLoaderModule.java | 121 ++++------- .../DownloadToCacheTest.java | 202 ------------------ .../VerifyAndInstallTest.java | 97 +++++++++ ios/BundleLoader.m | 23 +- ios/BundleLoaderTests/BundleLoaderTests.m | 31 +-- src/index.tsx | 88 +------- 6 files changed, 155 insertions(+), 407 deletions(-) delete mode 100644 android/src/test/java/com/reactnativebundleloader/DownloadToCacheTest.java create mode 100644 android/src/test/java/com/reactnativebundleloader/VerifyAndInstallTest.java diff --git a/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java b/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java index edcea01..b840990 100644 --- a/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java +++ b/android/src/main/java/com/reactnativebundleloader/BundleLoaderModule.java @@ -3,7 +3,6 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.util.Log; import androidx.annotation.NonNull; @@ -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"; @@ -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)) { @@ -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; } @@ -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); } } @@ -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. @@ -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(); - } - } } diff --git a/android/src/test/java/com/reactnativebundleloader/DownloadToCacheTest.java b/android/src/test/java/com/reactnativebundleloader/DownloadToCacheTest.java deleted file mode 100644 index 5c2d951..0000000 --- a/android/src/test/java/com/reactnativebundleloader/DownloadToCacheTest.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.reactnativebundleloader; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okio.Buffer; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -/** - * Drives {@link BundleLoaderModule#downloadToCache} against a real (loopback) - * HTTP server so the timeout/redirect/size-cap behavior is exercised end to - * end without an Android device or Robolectric. - */ -public class DownloadToCacheTest { - - // Short timeouts keep the suite snappy. 5s is plenty over loopback. - private static final int CONNECT_TIMEOUT_MS = 5_000; - private static final int READ_TIMEOUT_MS = 5_000; - // Default size cap is large; tests that need to trigger the cap pass a small - // value explicitly via the helper's maxBytes parameter. - private static final long DEFAULT_MAX_BYTES = 64L * 1024L * 1024L; - - private MockWebServer server; - private Path tmpDir; - private File targetFile; - - @Before - public void setUp() throws Exception { - server = new MockWebServer(); - server.start(); - tmpDir = Files.createTempDirectory("bundle-loader-test"); - targetFile = new File(tmpDir.toFile(), "verified-bundle.jsbundle"); - } - - @After - public void tearDown() throws Exception { - server.shutdown(); - if (targetFile.exists()) { - // noinspection ResultOfMethodCallIgnored - targetFile.delete(); - } - if (tmpDir != null) { - // noinspection ResultOfMethodCallIgnored - tmpDir.toFile().delete(); - } - } - - @Test - public void writesBodyOnHttp200() throws Exception { - byte[] body = "console.log('hello bundle');".getBytes("UTF-8"); - server.enqueue(new MockResponse().setResponseCode(200).setBody(new Buffer().write(body))); - - File written = BundleLoaderModule.downloadToCache( - server.url("/bundle.js").toString(), - targetFile, - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - DEFAULT_MAX_BYTES - ); - - assertNotNull(written); - assertEquals(targetFile.getAbsolutePath(), written.getAbsolutePath()); - assertTrue("target file must exist after a 200", written.exists()); - assertArrayEquals(body, Files.readAllBytes(written.toPath())); - } - - @Test - public void throwsOnHttp404AndDoesNotWriteFile() throws Exception { - server.enqueue(new MockResponse().setResponseCode(404).setBody("not found")); - - try { - BundleLoaderModule.downloadToCache( - server.url("/missing.js").toString(), - targetFile, - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - DEFAULT_MAX_BYTES - ); - fail("expected IOException for 404 response"); - } catch (IOException e) { - assertNotNull(e.getMessage()); - assertTrue( - "IOException must mention the HTTP status; got: " + e.getMessage(), - e.getMessage().contains("404") - ); - } - - assertFalse( - "non-200 response must not produce a target file", - targetFile.exists() - ); - } - - @Test - public void throwsOnHttp500AndDoesNotWriteFile() throws Exception { - server.enqueue(new MockResponse().setResponseCode(500).setBody("boom")); - - try { - BundleLoaderModule.downloadToCache( - server.url("/oops.js").toString(), - targetFile, - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - DEFAULT_MAX_BYTES - ); - fail("expected IOException for 500 response"); - } catch (IOException e) { - assertTrue( - "IOException must mention the HTTP status; got: " + e.getMessage(), - e.getMessage().contains("500") - ); - } - - assertFalse(targetFile.exists()); - } - - @Test - public void throwsWhenBodyExceedsMaxBytes() throws Exception { - // Lower the cap to make the test cheap. Body is twice the cap so the - // overflow trips well before EOF regardless of buffer alignment. - long maxBytes = 1024L; - byte[] body = new byte[(int) maxBytes * 2]; - for (int i = 0; i < body.length; i++) { - body[i] = (byte) (i & 0x7F); - } - server.enqueue(new MockResponse().setResponseCode(200).setBody(new Buffer().write(body))); - - try { - BundleLoaderModule.downloadToCache( - server.url("/big.js").toString(), - targetFile, - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - maxBytes - ); - fail("expected IOException when body exceeds maxBytes"); - } catch (IOException e) { - assertNotNull(e.getMessage()); - assertTrue( - "IOException must mention the byte cap; got: " + e.getMessage(), - e.getMessage().contains("exceeds") || e.getMessage().contains(String.valueOf(maxBytes)) - ); - } - - // The partial write may or may not have left bytes on disk depending on - // exactly when the loop tripped; what matters is we never returned a - // "valid" oversized bundle. Don't assert on file presence here. - } - - @Test - public void doesNotFollowRedirects() throws Exception { - // First (and only) response is a 302 pointing somewhere. With - // setInstanceFollowRedirects(false), the helper sees a non-200 status and - // throws — the would-be target body is never read, never written. - String redirectTarget = server.url("/elsewhere").toString(); - server.enqueue(new MockResponse() - .setResponseCode(302) - .addHeader("Location", redirectTarget)); - // Belt-and-braces: even if the helper *did* follow, we'd see this body. - // It must not appear on disk. - server.enqueue(new MockResponse() - .setResponseCode(200) - .setBody("REDIRECT_TARGET_BODY")); - - try { - BundleLoaderModule.downloadToCache( - server.url("/redirected.js").toString(), - targetFile, - CONNECT_TIMEOUT_MS, - READ_TIMEOUT_MS, - DEFAULT_MAX_BYTES - ); - fail("expected IOException because redirects are disabled"); - } catch (IOException e) { - assertTrue( - "IOException must mention the 302 status; got: " + e.getMessage(), - e.getMessage().contains("302") - ); - } - - assertFalse( - "redirect must not write any file to the target path", - targetFile.exists() - ); - // Exactly one request should have been made — the redirect was not chased. - assertEquals(1, server.getRequestCount()); - } -} diff --git a/android/src/test/java/com/reactnativebundleloader/VerifyAndInstallTest.java b/android/src/test/java/com/reactnativebundleloader/VerifyAndInstallTest.java new file mode 100644 index 0000000..82061f4 --- /dev/null +++ b/android/src/test/java/com/reactnativebundleloader/VerifyAndInstallTest.java @@ -0,0 +1,97 @@ +package com.reactnativebundleloader; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; + +import org.junit.Test; + +/** + * Drives {@link BundleLoaderModule#verifyAndInstall}: the canonical bundle path must + * only ever hold verified bytes, and the temp file must never be left behind. + */ +public class VerifyAndInstallTest { + + private static byte[] sha256(String s) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); + } + + private File freshDir() throws IOException { + File d = Files.createTempDirectory("bl-verify").toFile(); + d.deleteOnExit(); + return d; + } + + @Test + public void promotesTempToTargetOnHashMatch() throws Exception { + File dir = freshDir(); + File tmp = new File(dir, BundleLoaderModule.BUNDLE_TMP_FILENAME); + File target = new File(dir, BundleLoaderModule.BUNDLE_FILENAME); + byte[] body = "bundle-bytes".getBytes(StandardCharsets.UTF_8); + Files.write(tmp.toPath(), body); + byte[] digest = sha256("bundle-bytes"); + + boolean installed = BundleLoaderModule.verifyAndInstall(tmp, target, digest, digest); + + assertTrue("verified bundle should install", installed); + assertFalse("temp file must be gone after promotion", tmp.exists()); + assertTrue("target must exist", target.exists()); + assertArrayEquals(body, Files.readAllBytes(target.toPath())); + } + + @Test + public void deletesTempAndDoesNotCreateTargetOnMismatch() throws Exception { + File dir = freshDir(); + File tmp = new File(dir, BundleLoaderModule.BUNDLE_TMP_FILENAME); + File target = new File(dir, BundleLoaderModule.BUNDLE_FILENAME); + Files.write(tmp.toPath(), "attacker-bytes".getBytes(StandardCharsets.UTF_8)); + + boolean installed = BundleLoaderModule.verifyAndInstall( + tmp, target, sha256("attacker-bytes"), sha256("expected-good")); + + assertFalse("hash mismatch must not install", installed); + assertFalse("unverified temp must be deleted on mismatch", tmp.exists()); + assertFalse("canonical path must not be created on mismatch", target.exists()); + } + + @Test + public void doesNotClobberExistingVerifiedBundleOnMismatch() throws Exception { + File dir = freshDir(); + File tmp = new File(dir, BundleLoaderModule.BUNDLE_TMP_FILENAME); + File target = new File(dir, BundleLoaderModule.BUNDLE_FILENAME); + byte[] good = "previously-verified".getBytes(StandardCharsets.UTF_8); + Files.write(target.toPath(), good); + Files.write(tmp.toPath(), "bad".getBytes(StandardCharsets.UTF_8)); + + boolean installed = BundleLoaderModule.verifyAndInstall( + tmp, target, sha256("bad"), sha256("something-else")); + + assertFalse(installed); + assertFalse("temp deleted", tmp.exists()); + assertArrayEquals("existing verified bundle must be untouched", good, + Files.readAllBytes(target.toPath())); + } + + @Test + public void replacesExistingBundleOnHashMatch() throws Exception { + File dir = freshDir(); + File tmp = new File(dir, BundleLoaderModule.BUNDLE_TMP_FILENAME); + File target = new File(dir, BundleLoaderModule.BUNDLE_FILENAME); + Files.write(target.toPath(), "old".getBytes(StandardCharsets.UTF_8)); + byte[] body = "new-verified".getBytes(StandardCharsets.UTF_8); + Files.write(tmp.toPath(), body); + byte[] digest = sha256("new-verified"); + + boolean installed = BundleLoaderModule.verifyAndInstall(tmp, target, digest, digest); + + assertTrue(installed); + assertFalse(tmp.exists()); + assertArrayEquals(body, Files.readAllBytes(target.toPath())); + } +} diff --git a/ios/BundleLoader.m b/ios/BundleLoader.m index ca26bf6..cb5eca1 100644 --- a/ios/BundleLoader.m +++ b/ios/BundleLoader.m @@ -3,6 +3,11 @@ NSString * const RNBundleLoaderPendingURLKey = @"RNBundleLoaderPendingURL"; +// Defensive cap on the downloaded bundle size (parity with the Android module's +// MAX_BUNDLE_BYTES). Real bundles are ~50 MB; reject anything absurd before it is +// hashed, written, or loaded. +static const NSUInteger RNBundleLoaderMaxBundleBytes = 64UL * 1024UL * 1024UL; + @implementation BundleLoader @synthesize bridge = _bridge; @@ -23,16 +28,6 @@ - (void)setBundleURLAndReload:(NSURL *)url resolve(pending ? @"REMOTE" : @"LOCAL"); } -RCT_EXPORT_METHOD(load:(NSURL *)url) -{ - if (![[url scheme] isEqualToString:@"https"]) { - return; - } - dispatch_async(dispatch_get_main_queue(), ^{ - [self setBundleURLAndReload:url]; - }); -} - // Downloads the bundle at `urlString`, verifies its SHA-256 digest against // `expectedHex` using a constant-time byte comparison, then writes it to the // sandbox temp directory and reloads the bridge — all in native code to avoid @@ -86,6 +81,14 @@ - (void)setBundleURLAndReload:(NSURL *)url return; } + if (data.length > RNBundleLoaderMaxBundleBytes) { + reject(@"E_TOO_LARGE", + [NSString stringWithFormat:@"Bundle exceeds %lu bytes", + (unsigned long)RNBundleLoaderMaxBundleBytes], + nil); + return; + } + // Compute SHA-256 of the downloaded bytes. uint8_t actualDigest[CC_SHA256_DIGEST_LENGTH]; CC_SHA256(data.bytes, (CC_LONG)data.length, actualDigest); diff --git a/ios/BundleLoaderTests/BundleLoaderTests.m b/ios/BundleLoaderTests/BundleLoaderTests.m index f42bc52..709335f 100644 --- a/ios/BundleLoaderTests/BundleLoaderTests.m +++ b/ios/BundleLoaderTests/BundleLoaderTests.m @@ -5,7 +5,6 @@ @interface BundleLoader (Testing) - (void)setBundleURLAndReload:(NSURL *)url; -- (void)load:(NSURL *)url; - (void)loadVerifiedFromUrl:(NSString *)urlString expectedSha256:(NSString *)expectedHex resolver:(void (^)(id))resolve @@ -106,35 +105,7 @@ - (void)pumpMainRunloop } } -#pragma mark - Test 1: load rejects non-https - -- (void)testLoadRejectsNonHttps -{ - NSURL *url = [NSURL URLWithString:@"http://example.com/bundle.js"]; - [self.loader load:url]; - [self pumpMainRunloop]; - - XCTAssertEqual(self.mockBridge.kvcSets.count, 0u, - @"non-https URL must not trigger any KVC set"); - XCTAssertEqual(self.mockBridge.reloads.count, 0u, - @"non-https URL must not trigger reload"); -} - -#pragma mark - Test 2: load accepts https - -- (void)testLoadAcceptsHttps -{ - NSURL *url = [NSURL URLWithString:@"https://example.com/bundle.js"]; - [self.loader load:url]; - [self pumpMainRunloop]; - - NSURL *stored = [[NSUserDefaults standardUserDefaults] URLForKey:RNBundleLoaderPendingURLKey]; - XCTAssertEqualObjects(stored, url, @"https URL must be written to NSUserDefaults"); - XCTAssertEqual(self.mockBridge.kvcSets.count, 0u, @"no KVC sets expected"); - XCTAssertEqual(self.mockBridge.reloads.count, 1u, @"exactly one reload expected"); -} - -#pragma mark - Test 3: setBundleURLAndReload order +#pragma mark - Test 1: setBundleURLAndReload order - (void)testSetBundleURLAndReloadOrder { diff --git a/src/index.tsx b/src/index.tsx index c65a31c..1bfde93 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,22 +1,9 @@ /** @format */ -import React, { useCallback, useState } from 'react'; -import { - Alert, - Modal, - NativeModules, - NativeSyntheticEvent, - StyleSheet, - Text, - TextInput, - TextInputChangeEventData, - TouchableOpacity, - View, -} from 'react-native'; +import { NativeModules } from 'react-native'; export type RunningMode = 'LOCAL' | 'REMOTE'; type NativeBundleLoader = { - load(url: string): void; loadVerifiedFromUrl?(url: string, sha256: string): Promise; runningMode(): Promise; }; @@ -66,83 +53,16 @@ export async function loadVerified( await native.loadVerifiedFromUrl(url, expectedSha256Hex); } -function loadUnverified(url: string): void { - assertSafeUrl(url); - getNative().load(url); -} - async function runningMode(): Promise { return getNative().runningMode(); } +// Only the verified load path is exposed. The unverified `load()` path was +// removed (security): loading a remote bundle without native SHA-256 +// verification is a remote-code-execution primitive with no integrity check. const BundleLoader = { - load: loadUnverified, loadVerified, runningMode, }; export default BundleLoader; - -const styles = StyleSheet.create({ - container: { marginTop: 48, padding: 16, flex: 1 }, - input: { - height: 48, - marginTop: 8, - paddingHorizontal: 8, - borderColor: 'gray', - borderRadius: 4, - borderWidth: 1, - }, - button: { - backgroundColor: '#007AFF', - marginTop: 16, - height: 48, - justifyContent: 'center', - }, - buttonText: { - color: 'white', - alignSelf: 'center', - fontSize: 18, - alignContent: 'center', - }, -}); - -export function BundlePrompt() { - const [url, setUrl] = useState(''); - - const reload = useCallback(() => { - if (!url) { - Alert.alert('Oops…', 'You need to provide a URL'); - return; - } - try { - loadUnverified(url); - } catch (e) { - Alert.alert('Invalid URL', (e as Error).message); - } - }, [url]); - - return ( - - - ) => - setUrl(e.nativeEvent.text.trim()) - } - style={styles.input} - clearButtonMode="always" - autoFocus - placeholder="https://…" - /> - - Reload - - - - ); -} From 8c32c0bcf8de93918693b0ac60a9ff3d6181ff05 Mon Sep 17 00:00:00 2001 From: doguhan Date: Tue, 1 Sep 2026 01:04:30 +0200 Subject: [PATCH 2/3] docs: reflect verified-only API and current load mechanism - Remove unverified-load / BundlePrompt / Metro-tunnel docs and the load(url) API row. - Fix stale native descriptions: iOS uses NSUserDefaults + loadSourceForBridge (not KVC bundleURL); Android downloads to temp then atomically promotes. - SECURITY.md: drop obsolete mBundleLoader-reflection / KVC residual risks; document the removed unverified path, atomic verified install, size cap, and the host-driven session scoping. Co-Authored-By: Claude Opus 4.8 --- README.md | 37 +++++-------------------------------- SECURITY.md | 15 +++++++++++---- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 40271ba..99c41a6 100644 --- a/README.md +++ b/README.md @@ -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()`. 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`. diff --git a/SECURITY.md b/SECURITY.md index 8322492..9eb0da6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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` | @@ -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. From 49f5481169a99b311d3de217724f044c8f745b6e Mon Sep 17 00:00:00 2001 From: doguhan Date: Tue, 1 Sep 2026 11:25:17 +0200 Subject: [PATCH 3/3] test: drop stale references to the removed load() surface Review follow-up: the JS native-module mock no longer declares or stubs the removed native `load` method, and the iOS mock-bridge comment now reflects that the module writes the pending URL to NSUserDefaults and calls [_bridge reload] only (it no longer sets a value on the bridge). --- ios/BundleLoaderTests/BundleLoaderTests.m | 8 +++++--- src/__tests__/index.test.tsx | 3 --- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/ios/BundleLoaderTests/BundleLoaderTests.m b/ios/BundleLoaderTests/BundleLoaderTests.m index 709335f..708a67b 100644 --- a/ios/BundleLoaderTests/BundleLoaderTests.m +++ b/ios/BundleLoaderTests/BundleLoaderTests.m @@ -14,9 +14,11 @@ - (void)loadVerifiedFromUrl:(NSString *)urlString #pragma mark - Mock bridge /** - * The module talks to its bridge through `[_bridge setValue:forKey:]` and - * `[_bridge reload]` only. The mock therefore doesn't need to inherit from - * `RCTBridge` -- it just has to respond to those messages and keep a record. + * The production module writes the pending bundle URL to `NSUserDefaults` and + * calls `[_bridge reload]` only -- it never sets a value on the bridge. The mock + * still records any `setValue:forKey:` so a test can assert that none happen; it + * therefore doesn't need to inherit from `RCTBridge`, it just has to respond to + * those messages and keep a record. */ @interface BLMockBridge : NSObject @property (nonatomic, strong) NSMutableArray *kvcSets; diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index f126910..30aca93 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -4,13 +4,11 @@ import { NativeModules } from 'react-native'; import { loadVerified } from '../index'; type NativeMock = { - load: jest.Mock; loadVerifiedFromUrl: jest.Mock; runningMode: jest.Mock; }; const native: NativeMock = { - load: jest.fn(), loadVerifiedFromUrl: jest.fn(), runningMode: jest.fn(), }; @@ -75,7 +73,6 @@ describe('loadVerified — native wiring', () => { it('throws when loadVerifiedFromUrl is not available on the native module', async () => { const saved = (NativeModules as Record).BundleLoader; (NativeModules as Record).BundleLoader = { - load: jest.fn(), runningMode: jest.fn(), }; try {