mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(mobile): require exact activation metadata
This commit is contained in:
@@ -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
|
||||
|
||||
+40
@@ -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")
|
||||
+2
-12
@@ -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")
|
||||
}
|
||||
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-21
@@ -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()
|
||||
|
||||
+69
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user