mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(mobile): bound native manifest input
This commit is contained in:
@@ -1538,6 +1538,7 @@ copy.
|
||||
exact 65,536-character representation of 48 KiB and rejects oversized,
|
||||
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. Broader generated mutation, path/MIME/CSP, and persisted-cache
|
||||
@@ -2573,4 +2574,6 @@ copy.
|
||||
| 2026-07-28 | Complete | The strict root-route source contract passes the full 568-file / 3,373-test mobile suite with 2 expected skips, and the refreshed Android native module passes 76 Gradle tasks. A fresh exact-app emulator rerun remains part of the broader navigation gate. |
|
||||
| 2026-07-28 | Finding | The shared package response schema capped base64 chunks before decoding, but the native Swift and Kotlin stores decoded first and checked only the resulting 48 KiB byte limit. Both native stores now reject more than 65,536 encoded characters before invoking their base64 decoders. |
|
||||
| 2026-07-28 | Complete | Mirrored 65,537-character native chunk regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No RNW package content changed. |
|
||||
| 2026-07-28 | Finding | Native manifest parsing enforced asset count and field bounds only after parsing both supplied JSON documents. Swift and Kotlin now reject either raw manifest above 256 KiB before handing it to `JSONSerialization` or `JSONObject`. |
|
||||
| 2026-07-28 | Complete | Mirrored oversized primary/canonical manifest regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No staging directory is created for the rejected Android inputs. |
|
||||
| 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. |
|
||||
|
||||
@@ -485,19 +485,19 @@ IDs, and unrelated provider state never enter the page result.
|
||||
|
||||
## Production Contract Status
|
||||
|
||||
| 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/Boolean numeric scalar confusion before staging |
|
||||
| Manifest resource bounds | Implemented, measured, and focused-test passing | 48 KiB chunks / 65,536 base64 chars before native decode, 10 MiB/asset, 32 MiB/package, 256 assets; shared/Desktop/native limits pass; 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 |
|
||||
| Shell navigation events | Implemented and focused-test passing | Strict opaque routes with monotonic sequence, shell-session/build context, and one-shot restore |
|
||||
| Stable bridge errors | Implemented and focused-test passing | Strict stable enum; response/error schemas reject raw Desktop/native messages |
|
||||
| Shell compatibility range | Partial | Manifest range and exact v1 envelopes exist; supported-version release policy remains |
|
||||
| Bridge request/subscription caps | Implemented and focused-test passing | 640 KiB envelope, 64 pending requests, 32 subscriptions, per-operation byte/concurrency/rate grants |
|
||||
| Package read concurrency | Implemented and focused-test passing | Four concurrent 48 KiB reads / 192 KiB in flight per connection |
|
||||
| Terminal stream memory | Implemented and focused-test passing | 16 KiB input, 64 KiB output batch, 256 KiB outstanding, 2 MiB snapshot |
|
||||
| Native verified cache | Implemented and native-policy-test passing | 128 MiB per host, 512 MiB global, 16 MiB minimum free; active/session generations are protected; activation hashes are exact-type validated |
|
||||
| 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/Boolean numeric scalar confusion before staging |
|
||||
| Manifest resource bounds | Implemented, measured, and focused-test passing | 256 KiB/raw manifest before native parse; 48 KiB chunks / 65,536 base64 chars before native decode; 10 MiB/asset, 32 MiB/package, 256 assets |
|
||||
| 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 |
|
||||
| Shell navigation events | Implemented and focused-test passing | Strict opaque routes with monotonic sequence, shell-session/build context, and one-shot restore |
|
||||
| Stable bridge errors | Implemented and focused-test passing | Strict stable enum; response/error schemas reject raw Desktop/native messages |
|
||||
| Shell compatibility range | Partial | Manifest range and exact v1 envelopes exist; supported-version release policy remains |
|
||||
| Bridge request/subscription caps | Implemented and focused-test passing | 640 KiB envelope, 64 pending requests, 32 subscriptions, per-operation byte/concurrency/rate grants |
|
||||
| Package read concurrency | Implemented and focused-test passing | Four concurrent 48 KiB reads / 192 KiB in flight per connection |
|
||||
| Terminal stream memory | Implemented and focused-test passing | 16 KiB input, 64 KiB output batch, 256 KiB outstanding, 2 MiB snapshot |
|
||||
| Native verified cache | Implemented and native-policy-test passing | 128 MiB per host, 512 MiB global, 16 MiB minimum free; active/session generations are protected; activation hashes are exact-type validated |
|
||||
|
||||
## Next Inventory Action
|
||||
|
||||
|
||||
@@ -2718,9 +2718,10 @@ staging. The corpus found Android `JSONObject.optInt` string coercion and iOS
|
||||
JSON scalar types. The shared chunk envelope also caps base64 at the exact
|
||||
65,536-character encoding of 48 KiB and rejects type confusion, noncanonical
|
||||
encoding, decoded-length mismatch, and extra fields. Swift and Kotlin enforce
|
||||
that encoded ceiling before invoking their native decoders. Native activation
|
||||
metadata likewise requires string-typed active and previous hashes on both
|
||||
platforms; hash-shaped JSON numbers fail with
|
||||
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`. Generated mutation and the remaining
|
||||
path/MIME/CSP/cache corpus are still required.
|
||||
|
||||
|
||||
@@ -208,7 +208,8 @@ cross-scope races, privacy/authorization audit, and independent review.
|
||||
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. Android now requires the exact root document URL and rejects
|
||||
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; a fresh exact-app rerun,
|
||||
generated mutation, and the other listed boundaries remain.
|
||||
- [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay,
|
||||
|
||||
+9
@@ -13,6 +13,7 @@ import java.util.Base64
|
||||
|
||||
private const val CHUNK_BYTE_LIMIT = 48 * 1024
|
||||
private const val CHUNK_BASE64_CHARACTER_LIMIT = ((CHUNK_BYTE_LIMIT + 2) / 3) * 4
|
||||
private const val MANIFEST_JSON_BYTE_LIMIT = 256 * 1024
|
||||
private const val ASSET_BYTE_LIMIT = 10 * 1024 * 1024
|
||||
private val SHA256_PATTERN = Regex("^[a-f0-9]{64}$")
|
||||
private val SAFE_PATH_PATTERN = Regex("^[A-Za-z0-9._/-]+$")
|
||||
@@ -323,6 +324,10 @@ internal class MobileWebPackageStore internal constructor(
|
||||
manifestJson: String,
|
||||
canonicalManifestJson: String
|
||||
): MobileWebManifestRecord {
|
||||
require(
|
||||
isBoundedManifestJson(manifestJson) &&
|
||||
isBoundedManifestJson(canonicalManifestJson)
|
||||
) { "mobile_web_stage_manifest_invalid" }
|
||||
val manifest = parseJsonObject(manifestJson)
|
||||
val canonical = parseJsonObject(canonicalManifestJson)
|
||||
require(
|
||||
@@ -597,6 +602,10 @@ private fun parseJsonObject(value: String): JSONObject = try {
|
||||
throw IllegalArgumentException("mobile_web_stage_manifest_invalid")
|
||||
}
|
||||
|
||||
private fun isBoundedManifestJson(value: String): Boolean =
|
||||
value.length <= MANIFEST_JSON_BYTE_LIMIT &&
|
||||
value.toByteArray(Charsets.UTF_8).size <= MANIFEST_JSON_BYTE_LIMIT
|
||||
|
||||
private fun assetFile(root: File, path: String): File =
|
||||
path.split('/').fold(root) { parent, component -> File(parent, component) }
|
||||
|
||||
|
||||
+18
@@ -116,6 +116,24 @@ class MobileWebPackageStoreTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsOversizedManifestInputBeforeParsing() {
|
||||
val root = temporary.newFolder()
|
||||
val store = MobileWebPackageStore(root)
|
||||
val fixture = packageFixture()
|
||||
|
||||
listOf(
|
||||
" ".repeat(256 * 1024 + 1) to fixture.canonical,
|
||||
fixture.manifest to " ".repeat(256 * 1024 + 1)
|
||||
).forEach { (manifest, canonical) ->
|
||||
val error = assertThrows(IllegalArgumentException::class.java) {
|
||||
store.beginStage("paired-host", manifest, canonical)
|
||||
}
|
||||
assertEquals("mobile_web_stage_manifest_invalid", error.message)
|
||||
}
|
||||
assertFalse(root.walkTopDown().any { it.name == "staging" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletesAnInterruptedStageWhenTheStoreRestarts() {
|
||||
val root = temporary.newFolder()
|
||||
|
||||
@@ -15,6 +15,7 @@ enum MobileWebPackageStoreTests {
|
||||
try rejectsMalformedManifests(root: root.appendingPathComponent("manifests"))
|
||||
try rejectsQuotedNumericManifestFields(root: root.appendingPathComponent("scalar-types"))
|
||||
try rejectsBooleanNumericManifestFields(root: root.appendingPathComponent("boolean-types"))
|
||||
try rejectsOversizedManifestInput(root: root.appendingPathComponent("manifest-limit"))
|
||||
try deletesInterruptedStage(root: root.appendingPathComponent("interrupted"))
|
||||
try rejectsOversizedEncodedChunks(root: root.appendingPathComponent("chunk-limit"))
|
||||
try rejectsIncompleteAndCorruptGeneration(root: root.appendingPathComponent("corrupt"))
|
||||
@@ -155,6 +156,28 @@ enum MobileWebPackageStoreTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func rejectsOversizedManifestInput(root: URL) throws {
|
||||
let store = MobileWebPackageStore(cacheRoot: root)
|
||||
let fixture = try packageFixture()
|
||||
let oversized = String(repeating: " ", count: 256 * 1024 + 1)
|
||||
let invalid = [
|
||||
(oversized, fixture.canonical),
|
||||
(fixture.manifest, oversized),
|
||||
]
|
||||
|
||||
for (manifest, canonical) in invalid {
|
||||
precondition(
|
||||
throwsCode("mobile_web_stage_manifest_invalid") {
|
||||
_ = try store.beginStage(
|
||||
hostIdentity: "paired-host",
|
||||
manifestJson: manifest,
|
||||
canonicalManifestJson: canonical
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func deletesInterruptedStage(root: URL) throws {
|
||||
let first = MobileWebPackageStore(cacheRoot: root)
|
||||
let fixture = try packageFixture()
|
||||
|
||||
@@ -4,6 +4,7 @@ import Security
|
||||
|
||||
private let chunkByteLimit = 48 * 1024
|
||||
private let chunkBase64CharacterLimit = ((chunkByteLimit + 2) / 3) * 4
|
||||
private let manifestJsonByteLimit = 256 * 1024
|
||||
private let assetByteLimit = 10 * 1024 * 1024
|
||||
private let sha256Pattern = "^[a-f0-9]{64}$"
|
||||
private let safePathPattern = "^[A-Za-z0-9._/-]+$"
|
||||
@@ -476,6 +477,8 @@ final class MobileWebPackageStore {
|
||||
canonicalManifestJson: String
|
||||
) throws -> MobileWebManifestRecord {
|
||||
guard
|
||||
manifestJson.utf8.count <= manifestJsonByteLimit,
|
||||
canonicalManifestJson.utf8.count <= manifestJsonByteLimit,
|
||||
let manifest = try jsonObject(manifestJson) as? [String: Any],
|
||||
let canonical = try jsonObject(canonicalManifestJson) as? [String: Any],
|
||||
Set(manifest.keys)
|
||||
|
||||
Reference in New Issue
Block a user