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 c90da67d4b9..862bf27d3e7 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 @@ -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 | 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 5237486a0ca..7770f4e1773 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 @@ -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 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 ddae8d149b1..95a2faed57c 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 @@ -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 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 0d6ec85bc39..a3139c43d08 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 @@ -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, diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt index b959b19b044..a54159b2728 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt @@ -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('/') && diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt index 6142fc4c758..24c72f6eb79 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebPackageStoreTest.kt @@ -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() diff --git a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift index 50152193918..cab6c6fccf7 100644 --- a/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift +++ b/mobile/packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreTests.swift @@ -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() diff --git a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift index 35f53525e16..370cca9ad26 100644 --- a/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift +++ b/mobile/packages/expo-mobile-web-shell/ios/MobileWebPackageStore.swift @@ -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.. { } ) + 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` } diff --git a/src/shared/mobile-web/manifest-contract.ts b/src/shared/mobile-web/manifest-contract.ts index f14cfac7764..b831336b927 100644 --- a/src/shared/mobile-web/manifest-contract.ts +++ b/src/shared/mobile-web/manifest-contract.ts @@ -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') { diff --git a/src/shared/mobile-web/package-rpc-contract.ts b/src/shared/mobile-web/package-rpc-contract.ts index 8580417f78e..3c02b1b554c 100644 --- a/src/shared/mobile-web/package-rpc-contract.ts +++ b/src/shared/mobile-web/package-rpc-contract.ts @@ -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