fix(mobile): require exact activation metadata

This commit is contained in:
OrcaWin
2026-08-09 18:34:59 -04:00
committed by Jinwoo-H
parent b1e067a321
commit 6d4228cc44
13 changed files with 224 additions and 83 deletions
@@ -1548,10 +1548,13 @@ copy.
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. Manifest and package-RPC schemas now share one exact asset-path
predicate. A mirrored TypeScript, Swift, and Kotlin corpus rejects empty,
Native activation records now accept only an exact object containing
string-typed `active` and optional distinct `previous` hashes. The mirrored
Swift/Kotlin corpus rejects missing, null, Boolean, numeric, array, uppercase,
duplicate-generation, unknown-field, and trailing-token mutations with
`mobile_web_activation_invalid`. Manifest and package-RPC schemas now share
one exact asset-path predicate. A mirrored TypeScript, Swift, and Kotlin
corpus rejects empty,
absolute, traversal, repeated-separator, percent-encoded, query, fragment,
backslash, non-ASCII, overlong, and trailing-newline paths while accepting
only the reviewed relative form. Shared application SHA-256, Git object ID,
@@ -1570,7 +1573,7 @@ copy.
activation metadata at most 1 KiB plus one, and assets at most their declared
length plus one on both platforms. Mirrored Swift/Kotlin faults preserve the
stable generation and activation errors. Broader generated mutation, CSP
behavior, and cache metadata fuzzing remains open.
behavior, and generation-directory cache 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.
@@ -2643,4 +2646,6 @@ and diff hygiene pass. A fresh production RNW build remains
| 2026-07-28 | Complete | The first production bridge release policy is frozen at exact v2. Production packaging imports the shared range, additive features stay on v2 through capability negotiation, and a future floor cannot retire until its replacement has shipped in at least two stable mobile releases and the supported shell minimum advances. |
| 2026-07-28 | Complete | The production rollback runbook now distinguishes bad Desktop packages from native-shell/store incidents, maps automatic and manual host-scoped recovery, requires corrected release artifacts rather than cache mutation, defines privacy-safe support evidence, and preserves final physical/store-signed drills as open release gates. |
| 2026-07-28 | Complete | Post-runbook validation passes 569 mobile files / 3,378 tests with 2 expected skips. Mobile and mobile-web typechecks/lints, reliability, max-lines, formatting, diff hygiene, and the unchanged `b17ead7a…` 49-asset package verification pass. |
| 2026-07-28 | Finding | Swift `Decodable` ignored unknown activation fields, both stores admitted an explicit null or identical previous generation, and Android `JSONObject` accepted a valid activation object followed by trailing tokens. Both native stores now consume one exact activation object with only distinct lowercase string hashes. |
| 2026-07-28 | Complete | The mirrored two-valid/eleven-invalid activation corpus passes the iOS native fault executable and Android Debug unit/Release Kotlin gates. Full mobile validation remains 569 files / 3,378 tests with 2 expected skips; typechecks, lints, reliability, max-lines, formatting, diff hygiene, and unchanged `b17ead7a…` package verification pass. |
| 2026-07-28 | Next | Complete the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. |
@@ -521,7 +521,10 @@ Persisted primary and canonical manifests now use 256 KiB bounded readers,
activation records use a 1 KiB bounded reader, and assets use their declared
length on both native platforms. Each path reads only one overflow byte before
failing with its stable error, and mirrored Swift/Kotlin oversized-file faults
pass.
pass. Activation parsing is also exact on both platforms: only `active` and an
optional distinct `previous` string hash are accepted, while the mirrored
shape/type/trailing-token corpus fails with
`mobile_web_activation_invalid`.
The standalone renderer-based workspace, session, files, terminal,
source-control, and review presentation is also removed with its Vite-only
package path. A production-source boundary requires `src/mobile-web/` to remain
@@ -2732,10 +2732,12 @@ JSON scalar types. The shared chunk envelope also caps base64 at the exact
encoding, decoded-length mismatch, and extra fields. Swift and Kotlin enforce
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`. Manifest and package-RPC schemas now share one
exact asset-path predicate. A mirrored TypeScript, Swift, and Kotlin corpus
now accepts only an exact object containing string-typed `active` and optional
distinct `previous` hashes. A mirrored Swift/Kotlin corpus rejects missing,
null, Boolean, numeric, array, uppercase, duplicate-generation, unknown-field,
and trailing-token mutations with `mobile_web_activation_invalid`. Manifest
and package-RPC schemas now share one exact asset-path predicate. A mirrored
TypeScript, Swift, and Kotlin corpus
rejects empty, absolute, traversal, repeated-separator, percent-encoded, query,
fragment, backslash, non-ASCII, overlong, and trailing-newline paths while
accepting the reviewed relative form. Shared application SHA-256, Git object
@@ -2752,7 +2754,7 @@ canonical manifest reads at 256 KiB, activation records at 1 KiB, and asset
reads at the manifest-declared length before hashing or parsing. Each reader
consumes at most one overflow byte, and mirrored Swift/Kotlin faults preserve
the stable generation and activation errors. Generated mutation and the
remaining CSP/cache corpus are still required.
remaining CSP/generation-directory cache corpus are still required.
## App Store Gate
@@ -206,6 +206,11 @@ Post-runbook validation passes 569 mobile files / 3,378 tests with 2 expected
skips. Mobile and mobile-web typechecks/lints, reliability, max-lines,
formatting, diff hygiene, and the unchanged `b17ead7a…` package verification
pass.
Native activation metadata now consumes one exact object on both platforms.
The mirrored two-valid/eleven-invalid corpus closes unknown fields, null and
non-string hashes, identical active/previous generations, and trailing tokens.
The iOS native fault executable, Android Debug unit/Release Kotlin gates, and
the same broader validation pass.
The remaining security work below is release-app corpus testing, fuzzing,
cross-scope races, privacy/authorization audit, and independent review.
@@ -244,9 +249,11 @@ cross-scope races, privacy/authorization audit, and independent review.
`NSNumber`/`CFBoolean` integer bridging. Chunk base64 is capped at 65,536
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. Both native stores cap each raw manifest at 256 KiB before JSON
parsing. Android now requires the exact root document URL and rejects
accepts only exact `active` and optional distinct `previous` string
hashes. Its mirrored missing/null/Boolean/numeric/array/uppercase/
duplicate/unknown/trailing-token corpus fails with the same stable error
on both 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. One document-CSP contract
now drives packaging/verification and exact native source parity.
Manifest and package RPC reuse one exact path predicate, and the same
@@ -0,0 +1,40 @@
package expo.modules.mobilewebshell
import org.json.JSONObject
import org.json.JSONTokener
internal data class MobileWebActivationRecord(
val active: String,
val previous: String?
)
internal fun parseMobileWebActivationRecord(json: String): MobileWebActivationRecord {
val value = try {
val tokens = JSONTokener(json)
val candidate = tokens.nextValue()
require(candidate is JSONObject && tokens.nextClean() == '\u0000')
candidate
} catch (_: Exception) {
throw invalidActivation()
}
val keys = value.keys().asSequence().toSet()
require(keys == setOf("active") || keys == setOf("active", "previous")) {
"mobile_web_activation_invalid"
}
val active = value.opt("active") as? String ?: throw invalidActivation()
val previous = if ("previous" in keys) {
value.opt("previous") as? String ?: throw invalidActivation()
} else {
null
}
require(
isMobileWebSha256(active) &&
(previous == null || isMobileWebSha256(previous) && previous != active)
) {
"mobile_web_activation_invalid"
}
return MobileWebActivationRecord(active, previous)
}
private fun invalidActivation(): IllegalArgumentException =
IllegalArgumentException("mobile_web_activation_invalid")
@@ -458,23 +458,13 @@ internal class MobileWebPackageStore internal constructor(
}
private fun readActivation(hostRoot: File): Pair<String, String?> = try {
val value = parseJsonObject(
parseMobileWebActivationRecord(
readMobileWebFile(
File(hostRoot, "activation.json"),
ACTIVATION_JSON_BYTE_LIMIT,
"mobile_web_activation_invalid"
).toString(Charsets.UTF_8)
)
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")) {
strictJsonString(value, "previous")
?: throw IllegalArgumentException("mobile_web_activation_invalid")
} else {
null
}
require(isMobileWebSha256(active) && (previous == null || isMobileWebSha256(previous)))
active to previous
).let { it.active to it.previous }
} catch (_: Exception) {
throw IllegalArgumentException("mobile_web_activation_invalid")
}
@@ -0,0 +1,42 @@
package expo.modules.mobilewebshell
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class MobileWebActivationMetadataTest {
@Test
fun acceptsOnlyExactActivationMetadata() {
val active = "a".repeat(64)
val previous = "b".repeat(64)
assertEquals(
MobileWebActivationRecord(active, null),
parseMobileWebActivationRecord("""{"active":"$active"}""")
)
assertEquals(
MobileWebActivationRecord(active, previous),
parseMobileWebActivationRecord("""{"active":"$active","previous":"$previous"}""")
)
val numericHash = "1".repeat(64)
val invalid = listOf(
"[]",
"{}",
"""{"active":null}""",
"""{"active":true}""",
"""{"active":$numericHash}""",
"""{"active":"${active.uppercase()}"}""",
"""{"active":"$active","previous":null}""",
"""{"active":"$active","previous":true}""",
"""{"active":"$active","previous":"$active"}""",
"""{"active":"$active","unexpected":true}""",
"""{"active":"$active"} trailing"""
)
invalid.forEach { value ->
val error = assertThrows(IllegalArgumentException::class.java) {
parseMobileWebActivationRecord(value)
}
assertEquals("mobile_web_activation_invalid", error.message)
}
}
}
@@ -381,27 +381,6 @@ 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()
@@ -0,0 +1,69 @@
import CryptoKit
import Foundation
enum MobileWebActivationMetadataTests {
static func run(root: URL) throws {
let active = String(repeating: "a", count: 64)
let previous = String(repeating: "b", count: 64)
let valid = [
"{\"active\":\"\(active)\"}",
"{\"active\":\"\(active)\",\"previous\":\"\(previous)\"}",
]
precondition(
valid.allSatisfy {
parseMobileWebActivationRecord(Data($0.utf8)) != nil
}
)
let numericHash = String(repeating: "1", count: 64)
let invalid = [
"[]",
"{}",
"{\"active\":null}",
"{\"active\":true}",
"{\"active\":\(numericHash)}",
"{\"active\":\"\(active.uppercased())\"}",
"{\"active\":\"\(active)\",\"previous\":null}",
"{\"active\":\"\(active)\",\"previous\":true}",
"{\"active\":\"\(active)\",\"previous\":\"\(active)\"}",
"{\"active\":\"\(active)\",\"unexpected\":true}",
"{\"active\":\"\(active)\"} trailing",
]
precondition(
invalid.allSatisfy {
parseMobileWebActivationRecord(Data($0.utf8)) == nil
}
)
let store = MobileWebPackageStore(cacheRoot: root)
let hostRoot =
root
.appendingPathComponent(sha256Hex(Data("paired-host".utf8)))
try FileManager.default.createDirectory(at: hostRoot, withIntermediateDirectories: true)
for value in invalid {
try Data(value.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 throwsCode(_ code: String, _ body: () throws -> Void) -> Bool {
do {
try body()
return false
} catch {
return error.localizedDescription == code
}
}
private static func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
@@ -25,7 +25,9 @@ enum MobileWebPackageStoreTests {
try rejectsOversizedPersistedFiles(root: root.appendingPathComponent("persisted-limits"))
try activatesAndRecoversPreviousGeneration(root: root.appendingPathComponent("rollback"))
try fallsBackFromCorruptActiveGeneration(root: root.appendingPathComponent("corrupt-active"))
try rejectsNumericActivationFields(root: root.appendingPathComponent("activation-types"))
try MobileWebActivationMetadataTests.run(
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"))
@@ -513,31 +515,6 @@ 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,
@@ -0,0 +1,31 @@
import Foundation
struct MobileWebActivationRecord: Codable, Equatable {
let active: String
let previous: String?
}
func parseMobileWebActivationRecord(_ data: Data) -> MobileWebActivationRecord? {
guard
let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
Set(value.keys) == Set(["active"]) || Set(value.keys) == Set(["active", "previous"]),
let active = value["active"] as? String,
isMobileWebSha256(active)
else {
return nil
}
let previous: String?
if value.keys.contains("previous") {
guard
let candidate = value["previous"] as? String,
isMobileWebSha256(candidate),
candidate != active
else {
return nil
}
previous = candidate
} else {
previous = nil
}
return MobileWebActivationRecord(active: active, previous: previous)
}
@@ -41,11 +41,6 @@ struct MobileWebAssetResponse {
let isDocument: Bool
}
private struct MobileWebActivationRecord: Codable {
let active: String
let previous: String?
}
private final class MobileWebSessionRecord {
let hostKey: String
let buildId: String
@@ -648,11 +643,7 @@ final class MobileWebPackageStore {
byteLimit: activationJsonByteLimit,
overflowCode: "mobile_web_activation_invalid"
)
let activation = try JSONDecoder().decode(MobileWebActivationRecord.self, from: data)
guard
isMobileWebSha256(activation.active),
activation.previous == nil || isMobileWebSha256(activation.previous!)
else {
guard let activation = parseMobileWebActivationRecord(data) else {
throw MobileWebStoreError("mobile_web_activation_invalid")
}
return activation
@@ -19,7 +19,12 @@ try {
'swiftc',
'-DMOBILE_WEB_PACKAGE_STORE_TESTING',
join(mobileRoot, 'packages/expo-mobile-web-shell/ios/MobileWebCacheStoragePolicy.swift'),
join(mobileRoot, 'packages/expo-mobile-web-shell/ios/MobileWebActivationMetadata.swift'),
join(mobileRoot, 'packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift'),
join(
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebActivationMetadataTests.swift'
),
join(
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreProcessInterruptionTests.swift'