fix(mobile): reject boolean manifest integers

This commit is contained in:
OrcaWin
2026-08-09 18:34:58 -04:00
committed by Jinwoo-H
parent 417197f109
commit b28b0f993e
8 changed files with 120 additions and 19 deletions
@@ -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. |
@@ -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 |
@@ -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
@@ -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,
@@ -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()
@@ -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()
@@ -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("/"),
@@ -129,6 +129,32 @@ describe('mobile web manifest contract', () => {
expect(MobileWebManifestSchema.safeParse(manifest).success).toBe(false)
})
it.each([
['schemaVersion', (manifest: Record<string, unknown>) => (manifest.schemaVersion = true)],
[
'bridge.minimum',
(manifest: Record<string, unknown>) =>
((manifest.bridge as Record<string, unknown>).minimum = true)
],
[
'bridge.testedThrough',
(manifest: Record<string, unknown>) =>
((manifest.bridge as Record<string, unknown>).testedThrough = true)
],
['totalBytes', (manifest: Record<string, unknown>) => (manifest.totalBytes = true)],
[
'assets.byteLength',
(manifest: Record<string, unknown>) => {
const assets = manifest.assets as Record<string, unknown>[]
assets[0]!.byteLength = true
}
]
])('rejects Boolean numeric field %s', (_field, mutate) => {
const manifest = validManifest() as unknown as Record<string, unknown>
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 }