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 586c5e4f17d..af809de88de 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 @@ -2667,4 +2667,7 @@ and diff hygiene pass. A fresh production RNW build remains | 2026-07-28 | Finding | Native staged-asset writes validated hashes only after append, so an asset or ancestor replaced by a symlink could receive bytes outside the cache. Activation writers likewise trusted a previously verified host tree. | | 2026-07-28 | Complete | Both stores now require an unlinked cache descendant before staged or activation writes. Mirrored faults preserve external bytes for linked assets and host trees, and prove atomic activation replacement removes an in-cache file link without changing its target. iOS native faults and Android Debug unit/Release Kotlin gates pass. | | 2026-07-28 | Complete | Post-write-boundary validation passes the 569-file mobile suite with 3,378 tests and 2 expected skips, mobile/mobile-web typechecks and lints, reliability, max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package verification. | +| 2026-07-28 | Finding | Native exact-JSON scanners accepted syntactically escaped lone UTF-16 surrogates before handing strings to Foundation and `org.json`, whose Unicode handling can diverge. | +| 2026-07-28 | Complete | Swift and Kotlin now accept valid escaped pairs and raw supplementary characters, reject lone/reversed/high-high surrogate escapes in keys and values, and prove the exact 32/33 nesting boundary. Both native fault suites and Android Release Kotlin compilation pass. | +| 2026-07-28 | Complete | Post-parser validation passes the 569-file mobile suite with 3,378 tests and 2 expected skips, typechecks, lints, reliability, max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package verification. | | 2026-07-28 | Next | Complete the remaining gated 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 3f54da283cb..4a02573d091 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 @@ -531,7 +531,9 @@ manifest, activation, or executable asset bytes are consumed. Primary and canonical manifests plus activation metadata also pass a bounded exact JSON grammar before native parsing. Duplicate decoded keys, trailing tokens, malformed scalars, and nesting beyond 32 levels fail consistently on both -platforms. Cache mutation faults additionally require every destructive +platforms. Escaped Unicode surrogate pairs and raw supplementary characters +pass, while unpaired surrogate escapes in keys or values fail before platform +JSON parsing. Cache mutation faults additionally require every destructive Android store path to delete only lexical descendants without following symlinks; quota traversal ignores linked trees. Direct, nested, and live-stage-replacement links plus host-subtree and dangling host links preserve 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 f8a71b67314..7553a701e84 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 @@ -2844,6 +2844,12 @@ error. Activation uses an atomic sibling replacement, so an in-cache activation file link is replaced rather than followed and its external target remains unchanged. +Persisted manifest and activation parsing also applies one bounded exact JSON +grammar before Foundation or `org.json`. It rejects duplicate decoded keys, +trailing tokens, malformed numbers/escapes, nesting beyond 32 levels, and +unpaired Unicode surrogate escapes, avoiding platform-specific coercion or +Unicode behavior before schema validation. + ### Native-shell rollback A defect in the asset origin, credential broker, native bridge, audio/picker 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 41b9c7853f0..1e0b19b3e7d 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 @@ -241,6 +241,14 @@ in-cache link rather than modifying its external target. The 569-file mobile suite, mobile/mobile-web typechecks and lints, reliability, max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package verification also pass after the write-boundary repair. +The exact native JSON grammar now validates Unicode surrogate pairing before +Foundation or `org.json` parsing. Escaped pairs and raw supplementary +characters pass, while lone, reversed, or high/high surrogate escapes fail in +keys and values. Mirrored corpora also prove the exact 32-level acceptance and +33-level rejection boundary. +The 569-file mobile suite, typechecks, lints, reliability, max-lines, focused +formatting, diff hygiene, and unchanged `b17ead7a…` package verification remain +green after the parser repair. The remaining security work below is release-app corpus testing, fuzzing, cross-scope races, privacy/authorization audit, and independent review. @@ -303,9 +311,10 @@ cross-scope races, privacy/authorization audit, and independent review. symlink traversal without touching external sentinels; Android quota accounting ignores linked external bytes. Staged-asset and activation writes also reject linked parents, while atomic activation replacement - preserves an external file behind an in-cache link. A fresh exact-app - rerun, further generated mutation, concurrent cache mutation, and the - other listed boundaries remain. + preserves an external file behind an in-cache link. Exact JSON now rejects + unpaired Unicode surrogate escapes and has explicit depth-edge coverage. + A fresh exact-app rerun, further generated mutation, concurrent cache + 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/MobileWebExactJson.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebExactJson.kt index ac9b1e709f6..413c21e6a86 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebExactJson.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebExactJson.kt @@ -72,13 +72,23 @@ private class MobileWebExactJsonParser( val character = value[index++] if (character == '"') return value.substring(start, index) if (character.code < 0x20) return null + if (character.isHighSurrogate()) { + if (index >= value.length || !value[index].isLowSurrogate()) return null + index += 1 + continue + } + if (character.isLowSurrogate()) return null if (character != '\\') continue if (index >= value.length) return null val escaped = value[index++] if (escaped == 'u') { - if (index + 4 > value.length) return null - repeat(4) { - if (!value[index++].isHexDigit()) return null + val codeUnit = parseUnicodeCodeUnit() ?: return null + if (codeUnit in 0xD800..0xDBFF) { + if (!consume('\\') || !consume('u')) return null + val lowSurrogate = parseUnicodeCodeUnit() ?: return null + if (lowSurrogate !in 0xDC00..0xDFFF) return null + } else if (codeUnit in 0xDC00..0xDFFF) { + return null } } else if (escaped !in "\"\\/bfnrt") { return null @@ -87,6 +97,16 @@ private class MobileWebExactJsonParser( return null } + private fun parseUnicodeCodeUnit(): Int? { + if (index + 4 > value.length) return null + var codeUnit = 0 + repeat(4) { + val digit = value[index++].hexValue() ?: return null + codeUnit = (codeUnit shl 4) or digit + } + return codeUnit + } + private fun parseNumber(): Boolean { val start = index consume('-') @@ -137,8 +157,16 @@ private class MobileWebExactJsonParser( null } - private fun Char.isHexDigit(): Boolean = - isJsonDigit() || this in 'A'..'F' || this in 'a'..'f' + private fun Char.hexValue(): Int? = when (this) { + in '0'..'9' -> code - '0'.code + in 'A'..'F' -> code - 'A'.code + 10 + in 'a'..'f' -> code - 'a'.code + 10 + else -> null + } private fun Char.isJsonDigit(): Boolean = this in '0'..'9' + + private fun Char.isHighSurrogate(): Boolean = code in 0xD800..0xDBFF + + private fun Char.isLowSurrogate(): Boolean = code in 0xDC00..0xDFFF } diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebExactJsonTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebExactJsonTest.kt index 8f7ec34d47a..8a7c4938adb 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebExactJsonTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebExactJsonTest.kt @@ -10,11 +10,13 @@ class MobileWebExactJsonTest { val valid = listOf( "{}", """{"a":1,"b":[true,false,null,{"c":"\u0063"}]}""", - """{"a":-1.25e+2}""" + """{"a":-1.25e+2}""", + """{"emoji":"\uD83D\uDE00"}""", + """{"emoji":"😀"}""", + nestedObject(32) ) valid.forEach { assertTrue(it, isExactMobileWebJsonDocument(it)) } - val deeplyNested = """{"a":""".repeat(34) + "0" + "}".repeat(34) val invalid = listOf( "", """{"a":1} trailing""", @@ -27,8 +29,15 @@ class MobileWebExactJsonTest { """{"a":1٢}""", """{"a":1,}""", """{"a":"\x"}""", - deeplyNested + """{"a":"\uD800"}""", + """{"a":"\uDC00"}""", + """{"a":"\uD800\uD800"}""", + """{"\uD800":1}""", + nestedObject(33) ) invalid.forEach { assertFalse(it, isExactMobileWebJsonDocument(it)) } } + + private fun nestedObject(depth: Int): String = + """{"a":""".repeat(depth) + "0" + "}".repeat(depth) } diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebExactJsonTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebExactJsonTests.swift index 5faaf3672bc..5c880de47ac 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebExactJsonTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebExactJsonTests.swift @@ -6,13 +6,12 @@ enum MobileWebExactJsonTests { "{}", #"{"a":1,"b":[true,false,null,{"c":"\u0063"}]}"#, #"{"a":-1.25e+2}"#, + #"{"emoji":"\uD83D\uDE00"}"#, + #"{"emoji":"😀"}"#, + nestedObject(depth: 32), ] precondition(valid.allSatisfy(isExactMobileWebJsonDocument)) - let deeplyNested = - String(repeating: #"{"a":"#, count: 34) - + "0" - + String(repeating: "}", count: 34) let invalid = [ "", #"{"a":1} trailing"#, @@ -25,8 +24,18 @@ enum MobileWebExactJsonTests { #"{"a":1٢}"#, #"{"a":1,}"#, #"{"a":"\x"}"#, - deeplyNested, + #"{"a":"\uD800"}"#, + #"{"a":"\uDC00"}"#, + #"{"a":"\uD800\uD800"}"#, + #"{"\uD800":1}"#, + nestedObject(depth: 33), ] precondition(invalid.allSatisfy { !isExactMobileWebJsonDocument($0) }) } + + private static func nestedObject(depth: Int) -> String { + String(repeating: #"{"a":"#, count: depth) + + "0" + + String(repeating: "}", count: depth) + } } diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebExactJson.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebExactJson.swift index 7c219ec2a6e..738369e1737 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebExactJson.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebExactJson.swift @@ -88,11 +88,19 @@ private struct MobileWebExactJsonParser { let escaped = bytes[index] index += 1 if escaped == 0x75 { - guard index + 4 <= bytes.count else { return nil } - for byte in bytes[index..<(index + 4)] where !isHexDigit(byte) { + guard let codeUnit = parseUnicodeCodeUnit() else { return nil } + if (0xD800...0xDBFF).contains(codeUnit) { + guard + consumeByte(0x5C), + consumeByte(0x75), + let lowSurrogate = parseUnicodeCodeUnit(), + (0xDC00...0xDFFF).contains(lowSurrogate) + else { + return nil + } + } else if (0xDC00...0xDFFF).contains(codeUnit) { return nil } - index += 4 } else if ![0x22, 0x5C, 0x2F, 0x62, 0x66, 0x6E, 0x72, 0x74].contains(escaped) { return nil } @@ -100,6 +108,17 @@ private struct MobileWebExactJsonParser { return nil } + private mutating func parseUnicodeCodeUnit() -> UInt16? { + guard index + 4 <= bytes.count else { return nil } + var codeUnit: UInt16 = 0 + for byte in bytes[index..<(index + 4)] { + guard let digit = hexValue(byte) else { return nil } + codeUnit = (codeUnit << 4) | UInt16(digit) + } + index += 4 + return codeUnit + } + private mutating func parseNumber() -> Bool { let start = index _ = consumeByte(0x2D) @@ -171,7 +190,10 @@ private struct MobileWebExactJsonParser { (0x31...0x39).contains(byte) } - private func isHexDigit(_ byte: UInt8) -> Bool { - isDigit(byte) || (0x41...0x46).contains(byte) || (0x61...0x66).contains(byte) + private func hexValue(_ byte: UInt8) -> UInt8? { + if (0x30...0x39).contains(byte) { return byte - 0x30 } + if (0x41...0x46).contains(byte) { return byte - 0x41 + 10 } + if (0x61...0x66).contains(byte) { return byte - 0x61 + 10 } + return nil } }