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 4b001e4fa1c..bb3f89436f4 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 @@ -1534,8 +1534,10 @@ copy. JSON scalar types. The shared chunk envelope now caps encoded data at the exact 65,536-character representation of 48 KiB and rejects oversized, type-confused, noncanonical, length-mismatched, and extra-field responses. - Broader generated mutation, path/MIME/CSP, and persisted-cache metadata - fuzzing remains open. + 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 + metadata fuzzing remains open. - [ ] Fuzz bridge envelopes, schemas, sizes, IDs, ordering, cancellation, and subscription lifecycle. - [ ] Attempt cross-host, cross-build, cross-workspace, and cross-session races. @@ -2553,12 +2555,14 @@ copy. | 2026-07-28 | Complete | Photos-revocation validation passes 568 mobile files / 3,366 tests with 2 expected skips, both typechecks and lints, changed-file formatting, 55 reliability gates, max-lines, package verification, and diff hygiene. RNW remains `9ed8c7f7d9be87c85b2431ece4eac3365a73e62bebf409846dea0ce72c9d1dde`: 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | | 2026-07-28 | Complete | The exact iPhone 17 Pro / iOS 26.5 Photos picker remains presented across Home/foreground. Explicit Cancel returns to the same hosted Session while private origin and opaque workspace authority remain unchanged; the journey then completes post-grant revocation, denial, terminal/page isolation, and network/navigation isolation. | | 2026-07-28 | Complete | Picker-interruption validation passes 568 mobile files / 3,371 tests with 2 expected skips, both typechecks and lints, changed-file formatting, 55 reliability gates, max-lines, package verification, and diff hygiene. RNW remains `9ed8c7f7d9be87c85b2431ece4eac3365a73e62bebf409846dea0ce72c9d1dde`: 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | -| 2026-07-28 | Complete | Fresh exact-app iPhone 17 Pro / iOS 26.5 and Android API 36 arm64 Debug emulator runs loaded the active manifest-declared RNW script, rejected a mutated undeclared same-origin script path, and retained the hosted document. Both runs also passed network/navigation isolation; Android recorded zero sentinel observations and a clean bridge log. | -| 2026-07-28 | Complete | Executable-isolation validation passes 568 mobile files / 3,373 tests with 2 expected skips and 1 focused file / 23 tests. Mobile and RNW typechecks/lints, full mobile and changed-file formatting, 55 reliability gates, max-lines, native Swift faults, 76-task Android module tests, package verification, and diff hygiene pass. RNW remains `9ed8c7f7…`: 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | -| 2026-07-28 | Finding | Android native manifest parsing accepted quoted numeric schema, bridge, total-byte, and asset-byte fields through coercive `JSONObject.optInt`, unlike the strict shared TypeScript schema and iOS parser. The Android store now requires exact scalar types, and the same five-case deterministic corpus passes in TypeScript, Swift, and Kotlin before any stage is created. | -| 2026-07-28 | Complete | Manifest scalar hardening passes 1 shared contract file / 20 tests, the native Swift fault executable, and the refreshed Android module suite across 76 Gradle tasks. Node typecheck, changed TypeScript lint/formatting, max-lines, and diff hygiene pass. No RNW package content changed. | -| 2026-07-28 | Finding | Foundation bridges JSON `true` through `NSNumber`, and Swift `as? Int` accepted it as `1`; Boolean schema, bridge, total-byte, and asset-byte fields could therefore pass iOS native parsing. The store now excludes `CFBoolean` and accepts only integral JSON numbers. The mirrored five-case Boolean corpus passes in TypeScript, Swift, and Kotlin before staging. | -| 2026-07-28 | Complete | The combined ten-case manifest scalar corpus passes 1 shared contract file / 25 tests, the native Swift fault executable, and the refreshed Android module suite across 76 Gradle tasks. Node typecheck, changed TypeScript lint/formatting, max-lines, and diff hygiene pass. No RNW package content changed. | -| 2026-07-28 | Finding | The shared package chunk schema bounded decoded bytes but not the encoded `dataBase64` string before canonical decoding. It now enforces the exact 65,536-character ceiling for 48 KiB, and a focused corpus covers the ceiling plus quoted/Boolean numeric fields, non-Boolean EOF, unknown fields, noncanonical base64, and decoded-length mismatch. | -| 2026-07-28 | Complete | Package-request/chunk validation passes 1 shared file / 14 tests and the unchanged downloader passes 1 mobile file / 14 tests. Existing request-path/base64/length coverage is retained alongside the new chunk corpus. Node/mobile typechecks, changed-file lint/formatting, max-lines, and diff hygiene pass. | +| 2026-07-28 | Complete | Fresh exact-app iPhone 17 Pro / iOS 26.5 and Android API 36 arm64 Debug emulator runs loaded the active manifest-declared RNW script, rejected a mutated undeclared same-origin script path, and retained the hosted document. Both runs also passed network/navigation isolation; Android recorded zero sentinel observations and a clean bridge log. | +| 2026-07-28 | Complete | Executable-isolation validation passes 568 mobile files / 3,373 tests with 2 expected skips and 1 focused file / 23 tests. Mobile and RNW typechecks/lints, full mobile and changed-file formatting, 55 reliability gates, max-lines, native Swift faults, 76-task Android module tests, package verification, and diff hygiene pass. RNW remains `9ed8c7f7…`: 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | +| 2026-07-28 | Finding | Android native manifest parsing accepted quoted numeric schema, bridge, total-byte, and asset-byte fields through coercive `JSONObject.optInt`, unlike the strict shared TypeScript schema and iOS parser. The Android store now requires exact scalar types, and the same five-case deterministic corpus passes in TypeScript, Swift, and Kotlin before any stage is created. | +| 2026-07-28 | Complete | Manifest scalar hardening passes 1 shared contract file / 20 tests, the native Swift fault executable, and the refreshed Android module suite across 76 Gradle tasks. Node typecheck, changed TypeScript lint/formatting, max-lines, and diff hygiene pass. No RNW package content changed. | +| 2026-07-28 | Finding | Foundation bridges JSON `true` through `NSNumber`, and Swift `as? Int` accepted it as `1`; Boolean schema, bridge, total-byte, and asset-byte fields could therefore pass iOS native parsing. The store now excludes `CFBoolean` and accepts only integral JSON numbers. The mirrored five-case Boolean corpus passes in TypeScript, Swift, and Kotlin before staging. | +| 2026-07-28 | Complete | The combined ten-case manifest scalar corpus passes 1 shared contract file / 25 tests, the native Swift fault executable, and the refreshed Android module suite across 76 Gradle tasks. Node typecheck, changed TypeScript lint/formatting, max-lines, and diff hygiene pass. No RNW package content changed. | +| 2026-07-28 | Finding | The shared package chunk schema bounded decoded bytes but not the encoded `dataBase64` string before canonical decoding. It now enforces the exact 65,536-character ceiling for 48 KiB, and a focused corpus covers the ceiling plus quoted/Boolean numeric fields, non-Boolean EOF, unknown fields, noncanonical base64, and decoded-length mismatch. | +| 2026-07-28 | Complete | Package-request/chunk validation passes 1 shared file / 14 tests and the unchanged downloader passes 1 mobile file / 14 tests. Existing request-path/base64/length coverage is retained alongside the new chunk corpus. Node/mobile typechecks, changed-file lint/formatting, max-lines, and diff hygiene pass. | +| 2026-07-28 | Finding | Android activation metadata used coercive `JSONObject.optString`, so a 64-digit JSON number could become a hash-shaped active or previous build ID. Both native stores now require string-typed hashes and normalize malformed activation metadata to `mobile_web_activation_invalid`. | +| 2026-07-28 | Complete | Mirrored numeric active/previous regressions pass the native Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. Mobile formatting, max-lines, and diff hygiene pass. No RNW package content changed. | | 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 e8548e6b93a..718df0ffd90 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 @@ -488,7 +488,7 @@ IDs, and unrelated provider state never enter the page result. | 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, 10 MiB/asset, 32 MiB/package, 256 assets; shared, Desktop, Swift, and Kotlin limits pass; RNW has a 10 MiB CI budget | +| Manifest resource bounds | Implemented, measured, and focused-test passing | 48 KiB chunks / 65,536 base64 chars, 10 MiB/asset, 32 MiB/package, 256 assets; shared, Desktop, Swift, and Kotlin 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 | @@ -497,7 +497,7 @@ IDs, and unrelated provider state never enter the page result. | 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 | +| 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 758014a7a82..7a8a7376681 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 @@ -2714,8 +2714,11 @@ staging. The corpus found Android `JSONObject.optInt` string coercion and iOS `NSNumber`/`CFBoolean` integer bridging; both native parsers now require exact 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. Generated mutation and the -remaining path/MIME/CSP/cache corpus are still required. +encoding, decoded-length mismatch, and extra fields. Native activation +metadata now 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. ## App Store Gate 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 0b247982e9e..93ae81dce34 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 @@ -205,8 +205,10 @@ cross-scope races, privacy/authorization audit, and independent review. ten-case TypeScript/Swift/Kotlin quoted/Boolean numeric manifest corpus passes after removing Android `JSONObject.optInt` string coercion and iOS `NSNumber`/`CFBoolean` integer bridging. Chunk base64 is capped at 65,536 - characters and its bounded request/chunk mutation corpus passes; generated - mutation and the other listed boundaries remain. + characters and its bounded request/chunk mutation corpus passes. Native + activation metadata rejects numeric active/previous hashes with the same + stable error on both platforms; generated mutation and the other listed + boundaries remain. - [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay, reconnect, process-loss, and host-removal races. - [ ] Verify no credential or privileged host identity reaches URLs, DOM state, 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 6937fd60c84..a407875d38c 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 @@ -419,21 +419,20 @@ internal class MobileWebPackageStore internal constructor( throw IllegalArgumentException("mobile_web_generation_invalid") } - private fun readActivation(hostRoot: File): Pair { + private fun readActivation(hostRoot: File): Pair = try { val value = parseJsonObject(File(hostRoot, "activation.json").readText(Charsets.UTF_8)) - require(value.keys().asSequence().toSet().let { it == setOf("active") || it == setOf("active", "previous") }) { - "mobile_web_activation_invalid" - } - val active = value.optString("active") + require(value.keys().asSequence().toSet().let { it == setOf("active") || it == setOf("active", "previous") }) + val active = strictJsonString(value, "active") ?: "" val previous = if (value.has("previous") && !value.isNull("previous")) { - value.optString("previous") + strictJsonString(value, "previous") + ?: throw IllegalArgumentException("mobile_web_activation_invalid") } else { null } - require(SHA256_PATTERN.matches(active) && (previous == null || SHA256_PATTERN.matches(previous))) { - "mobile_web_activation_invalid" - } - return active to previous + require(SHA256_PATTERN.matches(active) && (previous == null || SHA256_PATTERN.matches(previous))) + active to previous + } catch (_: Exception) { + throw IllegalArgumentException("mobile_web_activation_invalid") } private fun writeActivation(hostRoot: File, active: String, previous: String?) { 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 39a37cdae75..3ca38be4513 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 @@ -212,6 +212,27 @@ class MobileWebPackageStoreTest { assertFalse(currentDocument.exists()) } + @Test + fun rejectsNumericActivationFields() { + val root = temporary.newFolder() + val store = MobileWebPackageStore(root) + val hostRoot = File(root, sha256Hex("paired-host".toByteArray())) + require(hostRoot.mkdirs()) + val numericHash = "1".repeat(64) + val validHash = "a".repeat(64) + + listOf( + """{"active":$numericHash}""", + """{"active":"$validHash","previous":$numericHash}""" + ).forEach { activation -> + File(hostRoot, "activation.json").writeText(activation) + val error = assertThrows(IllegalArgumentException::class.java) { + store.openSession("paired-host", null, 1) + } + assertEquals("mobile_web_activation_invalid", error.message) + } + } + @Test fun rejectsLowStorageBeforeCreatingAStage() { 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 25d9f3f791c..54ac96b89b7 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift @@ -19,6 +19,7 @@ enum MobileWebPackageStoreTests { try rejectsIncompleteAndCorruptGeneration(root: root.appendingPathComponent("corrupt")) try activatesAndRecoversPreviousGeneration(root: root.appendingPathComponent("rollback")) try fallsBackFromCorruptActiveGeneration(root: root.appendingPathComponent("corrupt-active")) + try rejectsNumericActivationFields(root: root.appendingPathComponent("activation-types")) try rejectsLowStorage(root: root.appendingPathComponent("low-storage")) try evictsUnprotectedGeneration(root: root.appendingPathComponent("eviction")) try evictsAnotherHostForGlobalQuota(root: root.appendingPathComponent("global-eviction")) @@ -297,6 +298,31 @@ enum MobileWebPackageStoreTests { precondition(!FileManager.default.fileExists(atPath: currentDocument.path)) } + private static func rejectsNumericActivationFields(root: URL) throws { + let store = MobileWebPackageStore(cacheRoot: root) + let hostRoot = root.appendingPathComponent(sha256Hex(Data("paired-host".utf8))) + try FileManager.default.createDirectory(at: hostRoot, withIntermediateDirectories: true) + let numericHash = String(repeating: "1", count: 64) + let validHash = String(repeating: "a", count: 64) + let invalid = [ + #"{"active":\#(numericHash)}"#, + #"{"active":"\#(validHash)","previous":\#(numericHash)}"#, + ] + + for activation in invalid { + try Data(activation.utf8).write(to: hostRoot.appendingPathComponent("activation.json")) + precondition( + throwsCode("mobile_web_activation_invalid") { + _ = try store.openSession( + hostIdentity: "paired-host", + buildId: nil, + bridgeVersion: 1 + ) + } + ) + } + } + private static func rejectsLowStorage(root: URL) throws { let store = MobileWebPackageStore( cacheRoot: root, diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift index 8bf858096b1..beca0746aa4 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift @@ -603,13 +603,17 @@ final class MobileWebPackageStore { } private func readActivation(hostRoot: URL) throws -> MobileWebActivationRecord { - let data = try Data(contentsOf: hostRoot.appendingPathComponent("activation.json")) - let activation = try JSONDecoder().decode(MobileWebActivationRecord.self, from: data) - guard isSha256(activation.active), activation.previous == nil || isSha256(activation.previous!) - else { + do { + let data = try Data(contentsOf: hostRoot.appendingPathComponent("activation.json")) + let activation = try JSONDecoder().decode(MobileWebActivationRecord.self, from: data) + guard isSha256(activation.active), activation.previous == nil || isSha256(activation.previous!) + else { + throw MobileWebStoreError("mobile_web_activation_invalid") + } + return activation + } catch { throw MobileWebStoreError("mobile_web_activation_invalid") } - return activation } private func writeActivation(_ activation: MobileWebActivationRecord, hostRoot: URL) throws {