mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(mobile): require exact package paths
This commit is contained in:
@@ -1541,10 +1541,14 @@ copy.
|
||||
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. The packager and verifier now consume one document-CSP contract, and
|
||||
source tests require the iOS and Android response policies to match its exact
|
||||
directive sequence. Broader generated mutation, path/MIME/CSP behavior, and
|
||||
persisted-cache metadata fuzzing remains open.
|
||||
Android. 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. The packager and verifier now consume one
|
||||
document-CSP contract, and source tests require the iOS and Android response
|
||||
policies to match its exact directive sequence. Broader generated mutation,
|
||||
MIME/CSP behavior, 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.
|
||||
@@ -1751,6 +1755,12 @@ copy.
|
||||
|
||||
## Evidence Log
|
||||
|
||||
The exact asset-path slice passes the mirrored 18-case TypeScript, Swift, and
|
||||
Kotlin corpus: 2 shared files / 57 tests, the Swift native fault executable,
|
||||
and the refreshed Android module suite across 76 Gradle tasks. Node/mobile
|
||||
typechecks, lint, formatting, max-lines, and diff hygiene also pass. The change
|
||||
does not alter RNW package content.
|
||||
|
||||
| Date | Workstream | Evidence | Result |
|
||||
| ---------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-07-22 | Prototype | Targeted package/cache/bridge tests | Passed |
|
||||
|
||||
@@ -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/Boolean numeric scalar confusion before staging |
|
||||
| Multi-asset manifest v1 | Implemented and focused-test passing | One exact path predicate and mirrored TypeScript/Swift/Kotlin path corpus; exact scalar types 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 |
|
||||
| Document CSP | Implemented and focused-test passing | One shared directive contract drives packaging/verification and exact-sequence checks for both native response policies |
|
||||
| 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 |
|
||||
@@ -500,6 +500,13 @@ IDs, and unrelated provider state never enter the page result.
|
||||
| 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 |
|
||||
|
||||
Manifest and package-RPC validation now consume the same exact asset-path
|
||||
predicate. Swift additionally requires its regular-expression match range to
|
||||
cover the full string rather than ending before a trailing newline. The
|
||||
mirrored native/shared corpus rejects empty, absolute, traversal,
|
||||
repeated-separator, percent-encoded, query, fragment, backslash, non-ASCII,
|
||||
overlong, and trailing-newline paths.
|
||||
|
||||
## Next Inventory Action
|
||||
|
||||
The dedicated hosted Files, Preview, Source Control, Review, and Agent History
|
||||
|
||||
@@ -2722,10 +2722,14 @@ 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`. The RNW packager and verifier consume one
|
||||
`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. The RNW packager and verifier consume one
|
||||
document-CSP contract, and source tests require both native response policies
|
||||
to match its exact directive sequence. Generated mutation and the remaining
|
||||
path/MIME/CSP behavior/cache corpus are still required.
|
||||
MIME/CSP/cache corpus are still required.
|
||||
|
||||
## App Store Gate
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ manifest-declared content-addressed RNW script loads while a mutated undeclared
|
||||
same-origin script is rejected by the native manifest store. The hosted
|
||||
document remains intact, both platforms retain network/navigation isolation,
|
||||
and Android records zero sentinel observations plus a clean native bridge log.
|
||||
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.
|
||||
The remaining security work below is release-app corpus testing, fuzzing,
|
||||
cross-scope races, privacy/authorization audit, and independent review.
|
||||
|
||||
@@ -211,7 +215,9 @@ cross-scope races, privacy/authorization audit, and independent review.
|
||||
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; a fresh
|
||||
now drives packaging/verification and exact native source parity.
|
||||
Manifest and package RPC reuse one exact path predicate, and the same
|
||||
18-case path corpus passes in TypeScript, Swift, and Kotlin. A fresh
|
||||
exact-app rerun, generated mutation, and the other listed boundaries
|
||||
remain.
|
||||
- [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay,
|
||||
|
||||
+3
-3
@@ -346,7 +346,7 @@ internal class MobileWebPackageStore internal constructor(
|
||||
bridgeMinimum > 0 &&
|
||||
bridgeMinimum <= bridgeTestedThrough &&
|
||||
bridgeTestedThrough <= 65_535 &&
|
||||
isSafeAssetPath(entrypoint) &&
|
||||
isSafeMobileWebAssetPath(entrypoint) &&
|
||||
declaredTotalBytes in 1..(32 * 1024 * 1024)
|
||||
) { "mobile_web_stage_manifest_invalid" }
|
||||
val buildId = strictJsonString(manifest, "buildId") ?: ""
|
||||
@@ -376,7 +376,7 @@ internal class MobileWebPackageStore internal constructor(
|
||||
val contentType = strictJsonString(value, "contentType") ?: ""
|
||||
val role = strictJsonString(value, "role") ?: ""
|
||||
require(
|
||||
isSafeAssetPath(path) &&
|
||||
isSafeMobileWebAssetPath(path) &&
|
||||
SHA256_PATTERN.matches(hash) &&
|
||||
length in 1..ASSET_BYTE_LIMIT &&
|
||||
path !in assets &&
|
||||
@@ -609,7 +609,7 @@ private fun isBoundedManifestJson(value: String): Boolean =
|
||||
private fun assetFile(root: File, path: String): File =
|
||||
path.split('/').fold(root) { parent, component -> File(parent, component) }
|
||||
|
||||
private fun isSafeAssetPath(path: String): Boolean =
|
||||
internal fun isSafeMobileWebAssetPath(path: String): Boolean =
|
||||
path.length in 1..240 &&
|
||||
!path.startsWith('/') &&
|
||||
!path.endsWith('/') &&
|
||||
|
||||
+29
@@ -59,6 +59,35 @@ class MobileWebPackageStoreTest {
|
||||
assertFalse(root.walkTopDown().any { it.name == "staging" && it.listFiles()?.isNotEmpty() == true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsOnlyExactCanonicalAssetPaths() {
|
||||
val invalid = listOf(
|
||||
"",
|
||||
"../index.html",
|
||||
"./index.html",
|
||||
"/index.html",
|
||||
"index.html/",
|
||||
"assets//app.js",
|
||||
"assets\\app.js",
|
||||
"assets/app.js?query",
|
||||
"assets/app.js#fragment",
|
||||
"assets/%2e%2e/app.js",
|
||||
"assets/./app.js",
|
||||
"assets/../app.js",
|
||||
"assets/app.js\n",
|
||||
"a".repeat(241),
|
||||
"assets/café.js"
|
||||
)
|
||||
val valid = listOf(
|
||||
"index.html",
|
||||
"assets/${"a".repeat(64)}.js",
|
||||
"assets/a_b-c.d.js"
|
||||
)
|
||||
|
||||
invalid.forEach { assertFalse(it, isSafeMobileWebAssetPath(it)) }
|
||||
valid.forEach { assertEquals(it, true, isSafeMobileWebAssetPath(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsQuotedNumericManifestFieldsBeforeCreatingAStage() {
|
||||
val root = temporary.newFolder()
|
||||
|
||||
@@ -13,6 +13,7 @@ enum MobileWebPackageStoreTests {
|
||||
|
||||
try stagesAndReadsExactGeneration(root: root.appendingPathComponent("verified"))
|
||||
try rejectsMalformedManifests(root: root.appendingPathComponent("manifests"))
|
||||
acceptsOnlyExactCanonicalAssetPaths()
|
||||
try rejectsQuotedNumericManifestFields(root: root.appendingPathComponent("scalar-types"))
|
||||
try rejectsBooleanNumericManifestFields(root: root.appendingPathComponent("boolean-types"))
|
||||
try rejectsOversizedManifestInput(root: root.appendingPathComponent("manifest-limit"))
|
||||
@@ -91,6 +92,34 @@ enum MobileWebPackageStoreTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func acceptsOnlyExactCanonicalAssetPaths() {
|
||||
let invalid = [
|
||||
"",
|
||||
"../index.html",
|
||||
"./index.html",
|
||||
"/index.html",
|
||||
"index.html/",
|
||||
"assets//app.js",
|
||||
"assets\\app.js",
|
||||
"assets/app.js?query",
|
||||
"assets/app.js#fragment",
|
||||
"assets/%2e%2e/app.js",
|
||||
"assets/./app.js",
|
||||
"assets/../app.js",
|
||||
"assets/app.js\n",
|
||||
String(repeating: "a", count: 241),
|
||||
"assets/café.js",
|
||||
]
|
||||
let valid = [
|
||||
"index.html",
|
||||
"assets/\(String(repeating: "a", count: 64)).js",
|
||||
"assets/a_b-c.d.js",
|
||||
]
|
||||
|
||||
precondition(invalid.allSatisfy { !isSafeMobileWebAssetPath($0) })
|
||||
precondition(valid.allSatisfy(isSafeMobileWebAssetPath))
|
||||
}
|
||||
|
||||
private static func rejectsQuotedNumericManifestFields(root: URL) throws {
|
||||
let store = MobileWebPackageStore(cacheRoot: root)
|
||||
let valid = try packageFixture()
|
||||
|
||||
@@ -521,7 +521,7 @@ final class MobileWebPackageStore {
|
||||
let length = strictJsonInt(value["byteLength"]),
|
||||
let contentType = value["contentType"] as? String,
|
||||
let role = value["role"] as? String,
|
||||
isSafeAssetPath(path),
|
||||
isSafeMobileWebAssetPath(path),
|
||||
isSha256(hash),
|
||||
length > 0,
|
||||
length <= assetByteLimit,
|
||||
@@ -916,16 +916,16 @@ private func strictJsonInt(_ value: Any?) -> Int? {
|
||||
return Int(exactly: number.doubleValue)
|
||||
}
|
||||
|
||||
private func isSafeAssetPath(_ path: String) -> Bool {
|
||||
func isSafeMobileWebAssetPath(_ path: String) -> Bool {
|
||||
guard
|
||||
(1...240).contains(path.count),
|
||||
!path.hasPrefix("/"),
|
||||
!path.hasSuffix("/"),
|
||||
!path.contains("//"),
|
||||
!path.contains("\\"),
|
||||
!path.contains("?"),
|
||||
!path.contains("#"),
|
||||
path.count <= 240,
|
||||
path.range(of: safePathPattern, options: .regularExpression) != nil
|
||||
path.range(of: safePathPattern, options: .regularExpression) == path.startIndex..<path.endIndex
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MOBILE_WEB_MAX_ASSET_BYTES,
|
||||
MOBILE_WEB_MAX_ASSET_COUNT,
|
||||
MobileWebManifestSchema,
|
||||
isMobileWebAssetPath,
|
||||
serializeMobileWebManifestForBuildId,
|
||||
supportsMobileWebBridgeVersion,
|
||||
type MobileWebManifest
|
||||
@@ -70,6 +71,33 @@ describe('mobile web manifest contract', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'../index.html',
|
||||
'./index.html',
|
||||
'/index.html',
|
||||
'index.html/',
|
||||
'assets//app.js',
|
||||
'assets\\app.js',
|
||||
'assets/app.js?query',
|
||||
'assets/app.js#fragment',
|
||||
'assets/%2e%2e/app.js',
|
||||
'assets/./app.js',
|
||||
'assets/../app.js',
|
||||
'assets/app.js\n',
|
||||
'a'.repeat(241),
|
||||
'assets/café.js'
|
||||
])('rejects noncanonical package path %s', (path) => {
|
||||
expect(isMobileWebAssetPath(path)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['index.html', `assets/${SCRIPT_HASH}.js`, 'assets/a_b-c.d.js'])(
|
||||
'accepts canonical package path %s',
|
||||
(path) => {
|
||||
expect(isMobileWebAssetPath(path)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('requires the full asset hash in non-document paths', () => {
|
||||
const manifest = validManifest()
|
||||
manifest.assets[0] = { ...manifest.assets[0]!, path: `assets/${'e'.repeat(64)}.js` }
|
||||
|
||||
@@ -47,10 +47,7 @@ const ROLE_BY_EXTENSION = {
|
||||
|
||||
const MobileWebAssetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MOBILE_WEB_MAX_PATH_CHARS)
|
||||
.regex(SAFE_PATH_PATTERN)
|
||||
.refine(isNormalizedAssetPath, 'Asset path must be normalized and relative')
|
||||
.refine(isMobileWebAssetPath, 'Asset path must be normalized and relative')
|
||||
|
||||
export const MobileWebAssetSchema = z
|
||||
.object({
|
||||
@@ -101,6 +98,20 @@ export function supportsMobileWebBridgeVersion(
|
||||
)
|
||||
}
|
||||
|
||||
export function isMobileWebAssetPath(path: string): boolean {
|
||||
if (
|
||||
path.length < 1 ||
|
||||
path.length > MOBILE_WEB_MAX_PATH_CHARS ||
|
||||
SAFE_PATH_PATTERN.exec(path)?.[0] !== path ||
|
||||
path.startsWith('/') ||
|
||||
path.endsWith('/') ||
|
||||
path.includes('//')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return path.split('/').every((segment) => segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
export function serializeMobileWebManifestForBuildId(manifest: MobileWebManifest): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
@@ -120,13 +131,6 @@ export function serializeMobileWebManifestForBuildId(manifest: MobileWebManifest
|
||||
})
|
||||
}
|
||||
|
||||
function isNormalizedAssetPath(path: string): boolean {
|
||||
if (path.startsWith('/') || path.endsWith('/') || path.includes('//')) {
|
||||
return false
|
||||
}
|
||||
return path.split('/').every((segment) => segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
function validateContentAddressedAsset(asset: MobileWebAsset, context: z.RefinementCtx): void {
|
||||
if (asset.role === 'document') {
|
||||
if (asset.path !== 'index.html' || asset.contentType !== 'text/html; charset=utf-8') {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MOBILE_WEB_MAX_PATH_CHARS,
|
||||
MOBILE_WEB_PACKAGE_CHUNK_BYTES,
|
||||
MobileWebManifestSchema
|
||||
MobileWebManifestSchema,
|
||||
isMobileWebAssetPath
|
||||
} from './manifest-contract'
|
||||
|
||||
export const MOBILE_WEB_PACKAGE_MAX_CONCURRENT_READS = 4
|
||||
@@ -26,14 +26,10 @@ export const MOBILE_WEB_PACKAGE_ERROR_CODES = [
|
||||
] as const
|
||||
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/
|
||||
const SAFE_PATH_PATTERN = /^[A-Za-z0-9._/-]+$/
|
||||
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
const AssetPathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MOBILE_WEB_MAX_PATH_CHARS)
|
||||
.regex(SAFE_PATH_PATTERN)
|
||||
.refine(isNormalizedAssetPath, 'Asset path must be normalized and relative')
|
||||
.refine(isMobileWebAssetPath, 'Asset path must be normalized and relative')
|
||||
|
||||
export const MobileWebPackageManifestResponseSchema = z
|
||||
.object({
|
||||
@@ -82,13 +78,6 @@ export function isMobileWebPackageErrorCode(value: string): value is MobileWebPa
|
||||
return MOBILE_WEB_PACKAGE_ERROR_CODE_SET.has(value)
|
||||
}
|
||||
|
||||
function isNormalizedAssetPath(path: string): boolean {
|
||||
if (path.startsWith('/') || path.endsWith('/') || path.includes('//')) {
|
||||
return false
|
||||
}
|
||||
return path.split('/').every((segment) => segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
function decodedBase64Length(value: string): number {
|
||||
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0
|
||||
return (value.length / 4) * 3 - padding
|
||||
|
||||
Reference in New Issue
Block a user