From 36a9bece9ffa940eae6633abcbd1f49891ef2987 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:04:54 -0700 Subject: [PATCH] fix(mobile): bound native manifest input --- ...hybrid-webview-implementation-checklist.md | 3 +++ ...-mobile-hybrid-webview-parity-inventory.md | 26 +++++++++---------- ...bile-hybrid-webview-single-pr-migration.md | 7 ++--- ...27-mobile-hybrid-webview-remaining-work.md | 3 ++- .../mobilewebshell/MobileWebPackageStore.kt | 9 +++++++ .../MobileWebPackageStoreTest.kt | 18 +++++++++++++ .../MobileWebPackageStoreTests.swift | 23 ++++++++++++++++ .../ios/MobileWebPackageStore.swift | 3 +++ 8 files changed, 75 insertions(+), 17 deletions(-) diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index db288e1e9bb..421af359f1a 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -1538,6 +1538,7 @@ copy. exact 65,536-character representation of 48 KiB and rejects oversized, type-confused, noncanonical, length-mismatched, and extra-field responses. Swift and Kotlin enforce the same encoded ceiling before base64 decoding. + They also cap each raw manifest document at 256 KiB before JSON parsing. Native activation records also require exact string-typed active/previous hashes; numeric hash-shaped values fail with the same stable error on iOS and Android. Broader generated mutation, path/MIME/CSP, and persisted-cache @@ -2573,4 +2574,6 @@ copy. | 2026-07-28 | Complete | The strict root-route source contract passes the full 568-file / 3,373-test mobile suite with 2 expected skips, and the refreshed Android native module passes 76 Gradle tasks. A fresh exact-app emulator rerun remains part of the broader navigation gate. | | 2026-07-28 | Finding | The shared package response schema capped base64 chunks before decoding, but the native Swift and Kotlin stores decoded first and checked only the resulting 48 KiB byte limit. Both native stores now reject more than 65,536 encoded characters before invoking their base64 decoders. | | 2026-07-28 | Complete | Mirrored 65,537-character native chunk regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No RNW package content changed. | +| 2026-07-28 | Finding | Native manifest parsing enforced asset count and field bounds only after parsing both supplied JSON documents. Swift and Kotlin now reject either raw manifest above 256 KiB before handing it to `JSONSerialization` or `JSONObject`. | +| 2026-07-28 | Complete | Mirrored oversized primary/canonical manifest regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No staging directory is created for the rejected Android inputs. | | 2026-07-28 | Next | Complete the remaining parity inventory and cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 749a0e48985..f381216af1e 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -485,19 +485,19 @@ IDs, and unrelated provider state never enter the page result. ## Production Contract Status -| Contract | Status | Source | -| -------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Multi-asset manifest v1 | Implemented and focused-test passing | `src/shared/mobile-web/manifest-contract.ts`; TypeScript, Swift, and Kotlin reject quoted/Boolean numeric scalar confusion before staging | -| Manifest resource bounds | Implemented, measured, and focused-test passing | 48 KiB chunks / 65,536 base64 chars before native decode, 10 MiB/asset, 32 MiB/package, 256 assets; shared/Desktop/native limits pass; RNW has a 10 MiB CI budget | -| Bridge envelope and capabilities | Implemented and focused-test passing | `src/shared/mobile-web/bridge-contract.ts`; bounded native-chat schemas and remaining operation payload schemas | -| Terminal stream | Implemented and focused-test passing | `src/shared/mobile-web/terminal-stream-contract.ts`; broker adapter and real-stream validation remain | -| Shell navigation events | Implemented and focused-test passing | Strict opaque routes with monotonic sequence, shell-session/build context, and one-shot restore | -| Stable bridge errors | Implemented and focused-test passing | Strict stable enum; response/error schemas reject raw Desktop/native messages | -| Shell compatibility range | Partial | Manifest range and exact v1 envelopes exist; supported-version release policy remains | -| Bridge request/subscription caps | Implemented and focused-test passing | 640 KiB envelope, 64 pending requests, 32 subscriptions, per-operation byte/concurrency/rate grants | -| Package read concurrency | Implemented and focused-test passing | Four concurrent 48 KiB reads / 192 KiB in flight per connection | -| Terminal stream memory | Implemented and focused-test passing | 16 KiB input, 64 KiB output batch, 256 KiB outstanding, 2 MiB snapshot | -| Native verified cache | Implemented and native-policy-test passing | 128 MiB per host, 512 MiB global, 16 MiB minimum free; active/session generations are protected; activation hashes are exact-type validated | +| Contract | Status | Source | +| -------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Multi-asset manifest v1 | Implemented and focused-test passing | `src/shared/mobile-web/manifest-contract.ts`; TypeScript, Swift, and Kotlin reject quoted/Boolean numeric scalar confusion before staging | +| Manifest resource bounds | Implemented, measured, and focused-test passing | 256 KiB/raw manifest before native parse; 48 KiB chunks / 65,536 base64 chars before native decode; 10 MiB/asset, 32 MiB/package, 256 assets | +| Bridge envelope and capabilities | Implemented and focused-test passing | `src/shared/mobile-web/bridge-contract.ts`; bounded native-chat schemas and remaining operation payload schemas | +| Terminal stream | Implemented and focused-test passing | `src/shared/mobile-web/terminal-stream-contract.ts`; broker adapter and real-stream validation remain | +| Shell navigation events | Implemented and focused-test passing | Strict opaque routes with monotonic sequence, shell-session/build context, and one-shot restore | +| Stable bridge errors | Implemented and focused-test passing | Strict stable enum; response/error schemas reject raw Desktop/native messages | +| Shell compatibility range | Partial | Manifest range and exact v1 envelopes exist; supported-version release policy remains | +| Bridge request/subscription caps | Implemented and focused-test passing | 640 KiB envelope, 64 pending requests, 32 subscriptions, per-operation byte/concurrency/rate grants | +| Package read concurrency | Implemented and focused-test passing | Four concurrent 48 KiB reads / 192 KiB in flight per connection | +| Terminal stream memory | Implemented and focused-test passing | 16 KiB input, 64 KiB output batch, 256 KiB outstanding, 2 MiB snapshot | +| Native verified cache | Implemented and native-policy-test passing | 128 MiB per host, 512 MiB global, 16 MiB minimum free; active/session generations are protected; activation hashes are exact-type validated | ## Next Inventory Action diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 4f1224b6437..57d156e585c 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -2718,9 +2718,10 @@ staging. The corpus found Android `JSONObject.optInt` string coercion and iOS JSON scalar types. The shared chunk envelope also caps base64 at the exact 65,536-character encoding of 48 KiB and rejects type confusion, noncanonical encoding, decoded-length mismatch, and extra fields. Swift and Kotlin enforce -that encoded ceiling before invoking their native decoders. Native activation -metadata likewise requires string-typed active and previous hashes on both -platforms; hash-shaped JSON numbers fail with +that encoded ceiling before invoking their native decoders and cap each raw +manifest document at 256 KiB before JSON parsing. Native activation metadata +likewise requires string-typed active and previous hashes on both platforms; +hash-shaped JSON numbers fail with `mobile_web_activation_invalid`. Generated mutation and the remaining path/MIME/CSP/cache corpus are still required. diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index ba473218a68..2b39953fc83 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -208,7 +208,8 @@ cross-scope races, privacy/authorization audit, and independent review. characters in the shared schema and both native stores before decode; its bounded request/chunk mutation corpus passes. Native activation metadata rejects numeric active/previous hashes with the same stable error on both - platforms. Android now requires the exact root document URL and rejects + platforms. Both native stores cap each raw manifest at 256 KiB before JSON + parsing. Android now requires the exact root document URL and rejects percent-encoded or query-bearing asset requests; a fresh exact-app rerun, generated mutation, and the other listed boundaries remain. - [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay, diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt index 31336ef41bb..b959b19b044 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt @@ -13,6 +13,7 @@ import java.util.Base64 private const val CHUNK_BYTE_LIMIT = 48 * 1024 private const val CHUNK_BASE64_CHARACTER_LIMIT = ((CHUNK_BYTE_LIMIT + 2) / 3) * 4 +private const val MANIFEST_JSON_BYTE_LIMIT = 256 * 1024 private const val ASSET_BYTE_LIMIT = 10 * 1024 * 1024 private val SHA256_PATTERN = Regex("^[a-f0-9]{64}$") private val SAFE_PATH_PATTERN = Regex("^[A-Za-z0-9._/-]+$") @@ -323,6 +324,10 @@ internal class MobileWebPackageStore internal constructor( manifestJson: String, canonicalManifestJson: String ): MobileWebManifestRecord { + require( + isBoundedManifestJson(manifestJson) && + isBoundedManifestJson(canonicalManifestJson) + ) { "mobile_web_stage_manifest_invalid" } val manifest = parseJsonObject(manifestJson) val canonical = parseJsonObject(canonicalManifestJson) require( @@ -597,6 +602,10 @@ private fun parseJsonObject(value: String): JSONObject = try { throw IllegalArgumentException("mobile_web_stage_manifest_invalid") } +private fun isBoundedManifestJson(value: String): Boolean = + value.length <= MANIFEST_JSON_BYTE_LIMIT && + value.toByteArray(Charsets.UTF_8).size <= MANIFEST_JSON_BYTE_LIMIT + private fun assetFile(root: File, path: String): File = path.split('/').fold(root) { parent, component -> File(parent, component) } diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt index 8bdd5e5938e..6142fc4c758 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt @@ -116,6 +116,24 @@ class MobileWebPackageStoreTest { ) } + @Test + fun rejectsOversizedManifestInputBeforeParsing() { + val root = temporary.newFolder() + val store = MobileWebPackageStore(root) + val fixture = packageFixture() + + listOf( + " ".repeat(256 * 1024 + 1) to fixture.canonical, + fixture.manifest to " ".repeat(256 * 1024 + 1) + ).forEach { (manifest, canonical) -> + val error = assertThrows(IllegalArgumentException::class.java) { + store.beginStage("paired-host", manifest, canonical) + } + assertEquals("mobile_web_stage_manifest_invalid", error.message) + } + assertFalse(root.walkTopDown().any { it.name == "staging" }) + } + @Test fun deletesAnInterruptedStageWhenTheStoreRestarts() { val root = temporary.newFolder() diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift index 143157de226..50152193918 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift @@ -15,6 +15,7 @@ enum MobileWebPackageStoreTests { try rejectsMalformedManifests(root: root.appendingPathComponent("manifests")) try rejectsQuotedNumericManifestFields(root: root.appendingPathComponent("scalar-types")) try rejectsBooleanNumericManifestFields(root: root.appendingPathComponent("boolean-types")) + try rejectsOversizedManifestInput(root: root.appendingPathComponent("manifest-limit")) try deletesInterruptedStage(root: root.appendingPathComponent("interrupted")) try rejectsOversizedEncodedChunks(root: root.appendingPathComponent("chunk-limit")) try rejectsIncompleteAndCorruptGeneration(root: root.appendingPathComponent("corrupt")) @@ -155,6 +156,28 @@ enum MobileWebPackageStoreTests { } } + private static func rejectsOversizedManifestInput(root: URL) throws { + let store = MobileWebPackageStore(cacheRoot: root) + let fixture = try packageFixture() + let oversized = String(repeating: " ", count: 256 * 1024 + 1) + let invalid = [ + (oversized, fixture.canonical), + (fixture.manifest, oversized), + ] + + for (manifest, canonical) in invalid { + precondition( + throwsCode("mobile_web_stage_manifest_invalid") { + _ = try store.beginStage( + hostIdentity: "paired-host", + manifestJson: manifest, + canonicalManifestJson: canonical + ) + } + ) + } + } + private static func deletesInterruptedStage(root: URL) throws { let first = MobileWebPackageStore(cacheRoot: root) let fixture = try packageFixture() diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift index ffcadadee78..35f53525e16 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift @@ -4,6 +4,7 @@ import Security private let chunkByteLimit = 48 * 1024 private let chunkBase64CharacterLimit = ((chunkByteLimit + 2) / 3) * 4 +private let manifestJsonByteLimit = 256 * 1024 private let assetByteLimit = 10 * 1024 * 1024 private let sha256Pattern = "^[a-f0-9]{64}$" private let safePathPattern = "^[A-Za-z0-9._/-]+$" @@ -476,6 +477,8 @@ final class MobileWebPackageStore { canonicalManifestJson: String ) throws -> MobileWebManifestRecord { guard + manifestJson.utf8.count <= manifestJsonByteLimit, + canonicalManifestJson.utf8.count <= manifestJsonByteLimit, let manifest = try jsonObject(manifestJson) as? [String: Any], let canonical = try jsonObject(canonicalManifestJson) as? [String: Any], Set(manifest.keys)