From b28b0f993e6feb03b5e6bb2d3b88c445a6f1d31b Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:42:31 -0700 Subject: [PATCH] fix(mobile): reject boolean manifest integers --- ...hybrid-webview-implementation-checklist.md | 13 +++++--- ...-mobile-hybrid-webview-parity-inventory.md | 2 +- ...bile-hybrid-webview-single-pr-migration.md | 11 ++++--- ...27-mobile-hybrid-webview-remaining-work.md | 7 ++-- .../MobileWebPackageStoreTest.kt | 27 +++++++++++++++ .../MobileWebPackageStoreTests.swift | 33 +++++++++++++++++++ .../ios/MobileWebPackageStore.swift | 20 ++++++++--- .../mobile-web/manifest-contract.test.ts | 26 +++++++++++++++ 8 files changed, 120 insertions(+), 19 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 ca9dfad9b6f..f8e110968c9 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 @@ -1527,11 +1527,12 @@ copy. native nested component, Android, physical-device, Release, broader live adversarial interaction, and independent review remain open. - [~] Fuzz manifest, chunks, asset paths, MIME types, CSP, and cache metadata. - A mirrored TypeScript, Swift, and Kotlin scalar-type corpus rejects quoted - schema, bridge, total-byte, and asset-byte integers before staging. It found - and removed Android `JSONObject.optInt` coercion so native validation now - requires exact JSON integer/string types. Broader generated mutation, - chunk/path/MIME/CSP, and persisted-cache metadata fuzzing remains open. + A mirrored TypeScript, Swift, and Kotlin scalar-type corpus rejects quoted and + Boolean schema, bridge, total-byte, and asset-byte integers before staging. It + removed Android `JSONObject.optInt` string coercion and iOS + `NSNumber`/`CFBoolean` integer bridging so both native validators require exact + JSON scalar types. Broader generated mutation, chunk/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,4 +2554,6 @@ copy. | 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 | 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 783edeb8ff6..e2a23738ae2 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 @@ -487,7 +487,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 numeric scalar confusion before staging | +| 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, 10 MiB/asset, 32 MiB/package, 256 assets; shared, Desktop, Swift, and Kotlin limits are regression-checked, and 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 | 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 a2805ec167b..6d0649d737d 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 @@ -2708,11 +2708,12 @@ Both runs also pass network/navigation isolation; Android records zero sentinel observations and no native bridge error. This does not replace physical-device, store-signed release, fuzz, or independent adversarial evidence. -A mirrored TypeScript, Swift, and Kotlin manifest corpus also rejects quoted -numeric schema, bridge, total-byte, and asset-byte fields before native staging. -The corpus found Android `JSONObject.optInt` coercion; the native parser now -requires exact JSON scalar types. Generated mutation and the remaining -chunk/path/MIME/CSP/cache corpus are still required. +A mirrored TypeScript, Swift, and Kotlin manifest corpus also rejects quoted and +Boolean numeric schema, bridge, total-byte, and asset-byte fields before native +staging. The corpus found Android `JSONObject.optInt` string coercion and iOS +`NSNumber`/`CFBoolean` integer bridging; both native parsers now require exact +JSON scalar types. Generated mutation and the remaining chunk/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 d4d7d01aa82..c48f51e07cf 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 @@ -202,9 +202,10 @@ cross-scope races, privacy/authorization audit, and independent review. testing. - [ ] Fuzz manifests, chunks, paths, MIME types, CSP, cache metadata, bridge envelopes, limits, ordering, cancellation, and subscriptions. The - five-case TypeScript/Swift/Kotlin quoted-numeric manifest corpus passes - after removing Android `JSONObject.optInt` coercion; generated mutation - and the other listed boundaries remain. + ten-case TypeScript/Swift/Kotlin quoted/Boolean numeric manifest corpus + passes after removing Android `JSONObject.optInt` string coercion and iOS + `NSNumber`/`CFBoolean` integer bridging; 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/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt index 961725ed886..39a37cdae75 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 @@ -89,6 +89,33 @@ class MobileWebPackageStoreTest { ) } + @Test + fun rejectsBooleanNumericManifestFieldsBeforeCreatingAStage() { + val root = temporary.newFolder() + val store = MobileWebPackageStore(root) + val invalid = listOf( + packageFixture { _, manifest -> manifest.put("schemaVersion", true) }, + packageFixture { _, manifest -> + manifest.getJSONObject("bridge").put("minimum", true) + }, + packageFixture { _, manifest -> + manifest.getJSONObject("bridge").put("testedThrough", true) + }, + packageFixture { _, manifest -> manifest.put("totalBytes", true) }, + packageFixture(mutateAsset = { asset -> asset.put("byteLength", true) }) + ) + + invalid.forEach { fixture -> + val error = assertThrows(IllegalArgumentException::class.java) { + store.beginStage("paired-host", fixture.manifest, fixture.canonical) + } + assertEquals("mobile_web_stage_manifest_invalid", error.message) + } + assertFalse( + root.walkTopDown().any { it.name == "staging" && it.listFiles()?.isNotEmpty() == true } + ) + } + @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 afe20d09f41..25d9f3f791c 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift @@ -14,6 +14,7 @@ enum MobileWebPackageStoreTests { try stagesAndReadsExactGeneration(root: root.appendingPathComponent("verified")) try rejectsMalformedManifests(root: root.appendingPathComponent("manifests")) try rejectsQuotedNumericManifestFields(root: root.appendingPathComponent("scalar-types")) + try rejectsBooleanNumericManifestFields(root: root.appendingPathComponent("boolean-types")) try deletesInterruptedStage(root: root.appendingPathComponent("interrupted")) try rejectsIncompleteAndCorruptGeneration(root: root.appendingPathComponent("corrupt")) try activatesAndRecoversPreviousGeneration(root: root.appendingPathComponent("rollback")) @@ -120,6 +121,38 @@ enum MobileWebPackageStoreTests { } } + private static func rejectsBooleanNumericManifestFields(root: URL) throws { + let store = MobileWebPackageStore(cacheRoot: root) + let invalid = [ + try packageFixture { $0["schemaVersion"] = true }, + try packageFixture { manifest in + var bridge = manifest["bridge"] as! [String: Any] + bridge["minimum"] = true + manifest["bridge"] = bridge + }, + try packageFixture { manifest in + var bridge = manifest["bridge"] as! [String: Any] + bridge["testedThrough"] = true + manifest["bridge"] = bridge + }, + try packageFixture { $0["totalBytes"] = true }, + try packageFixture { manifest in + mutateAsset(&manifest) { $0["byteLength"] = true } + }, + ] + for fixture in invalid { + precondition( + throwsError { + _ = try store.beginStage( + hostIdentity: "paired-host", + manifestJson: fixture.manifest, + canonicalManifestJson: fixture.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 daf9ade94c1..8bf858096b1 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift @@ -478,19 +478,19 @@ final class MobileWebPackageStore { let canonical = try jsonObject(canonicalManifestJson) as? [String: Any], Set(manifest.keys) == Set(["schemaVersion", "buildId", "bridge", "entrypoint", "totalBytes", "assets"]), - manifest["schemaVersion"] as? Int == 1, + strictJsonInt(manifest["schemaVersion"]) == 1, let buildId = manifest["buildId"] as? String, isSha256(buildId), sha256Hex(Data(canonicalManifestJson.utf8)) == buildId, let bridge = manifest["bridge"] as? [String: Any], Set(bridge.keys) == Set(["minimum", "testedThrough"]), - let bridgeMinimum = bridge["minimum"] as? Int, - let bridgeTestedThrough = bridge["testedThrough"] as? Int, + let bridgeMinimum = strictJsonInt(bridge["minimum"]), + let bridgeTestedThrough = strictJsonInt(bridge["testedThrough"]), bridgeMinimum > 0, bridgeMinimum <= bridgeTestedThrough, bridgeTestedThrough <= 65_535, let entrypoint = manifest["entrypoint"] as? String, - let declaredTotalBytes = manifest["totalBytes"] as? Int, + let declaredTotalBytes = strictJsonInt(manifest["totalBytes"]), declaredTotalBytes > 0, declaredTotalBytes <= 32 * 1024 * 1024, let assets = manifest["assets"] as? [[String: Any]], @@ -513,7 +513,7 @@ final class MobileWebPackageStore { Set(value.keys) == Set(["path", "sha256", "byteLength", "contentType", "role"]), let path = value["path"] as? String, let hash = value["sha256"] as? String, - let length = value["byteLength"] as? Int, + let length = strictJsonInt(value["byteLength"]), let contentType = value["contentType"] as? String, let role = value["role"] as? String, isSafeAssetPath(path), @@ -897,6 +897,16 @@ private func isSha256(_ value: String) -> Bool { value.range(of: sha256Pattern, options: .regularExpression) != nil } +private func strictJsonInt(_ value: Any?) -> Int? { + guard + let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + return nil + } + return Int(exactly: number.doubleValue) +} + private func isSafeAssetPath(_ path: String) -> Bool { guard !path.hasPrefix("/"), diff --git a/src/shared/mobile-web/manifest-contract.test.ts b/src/shared/mobile-web/manifest-contract.test.ts index 6d7043ea6be..8294bf8451b 100644 --- a/src/shared/mobile-web/manifest-contract.test.ts +++ b/src/shared/mobile-web/manifest-contract.test.ts @@ -129,6 +129,32 @@ describe('mobile web manifest contract', () => { expect(MobileWebManifestSchema.safeParse(manifest).success).toBe(false) }) + it.each([ + ['schemaVersion', (manifest: Record) => (manifest.schemaVersion = true)], + [ + 'bridge.minimum', + (manifest: Record) => + ((manifest.bridge as Record).minimum = true) + ], + [ + 'bridge.testedThrough', + (manifest: Record) => + ((manifest.bridge as Record).testedThrough = true) + ], + ['totalBytes', (manifest: Record) => (manifest.totalBytes = true)], + [ + 'assets.byteLength', + (manifest: Record) => { + const assets = manifest.assets as Record[] + assets[0]!.byteLength = true + } + ] + ])('rejects Boolean numeric field %s', (_field, mutate) => { + const manifest = validManifest() as unknown as Record + mutate(manifest) + expect(MobileWebManifestSchema.safeParse(manifest).success).toBe(false) + }) + it('enforces bridge, per-asset, and file-count bounds', () => { const invalidBridge = validManifest() invalidBridge.bridge = { minimum: 3, testedThrough: 2 }