fix(mobile): reject unsafe cache file reads

This commit is contained in:
OrcaWin
2026-08-09 18:34:59 -04:00
committed by Jinwoo-H
parent 6d4228cc44
commit e2a4b2410e
12 changed files with 260 additions and 55 deletions
@@ -1572,8 +1572,12 @@ copy.
primary/canonical manifests now read at most 256 KiB plus one overflow byte,
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 generation-directory cache fuzzing remains open.
stable generation and activation errors. Every persisted read now also
requires a regular descendant of the native cache root. A mirrored corpus
rejects outside-root files, file and ancestor-directory symlinks,
directories, and missing files with the requested stable error. Broader
generated mutation, CSP behavior, and cache mutation/cleanup 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.
@@ -2648,4 +2652,6 @@ and diff hygiene pass. A fresh production RNW build remains
| 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 | Finding | Persisted cache reads bounded allocation and verified hashes but still followed a symlinked file or ancestor directory. Both stores now require a regular descendant of the native cache root before opening any staged asset, manifest, activation record, or committed asset. |
| 2026-07-28 | Complete | The mirrored cache-file corpus accepts an in-root regular file and rejects an outside-root file, file symlink, ancestor-directory symlink, directory, and missing file with the requested stable error. The iOS native fault executable, Android Debug unit/Release Kotlin gates, 569-file mobile suite, 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. |
@@ -524,7 +524,10 @@ failing with its stable error, and mirrored Swift/Kotlin oversized-file faults
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`.
`mobile_web_activation_invalid`. Persisted cache reads additionally require a
regular descendant of the native cache root. The mirrored boundary corpus
rejects outside-root, symlinked, non-regular, and missing inputs before any
manifest, activation, or executable asset bytes are consumed.
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
@@ -2753,8 +2753,11 @@ exact directive sequence. Both native stores now cap persisted primary and
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/generation-directory cache corpus are still required.
the stable generation and activation errors. Every persisted read also
requires a regular descendant of the native cache root; the mirrored corpus
rejects outside-root files, file and ancestor-directory symlinks, directories,
and missing files with the requested stable error. Generated mutation and the
remaining CSP/cache mutation and cleanup corpus are still required.
## App Store Gate
@@ -211,6 +211,11 @@ 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.
Persisted native cache reads now require a regular descendant of the cache
root before opening staged assets, manifests, activation metadata, or committed
assets. Mirrored Swift/Kotlin faults reject outside-root, symlinked,
non-regular, and missing paths with a stable error. Mutation and cleanup fuzzing
remains open below.
The remaining security work below is release-app corpus testing, fuzzing,
cross-scope races, privacy/authorization audit, and independent review.
@@ -263,9 +268,11 @@ cross-scope races, privacy/authorization audit, and independent review.
shared extension/MIME/role map now has exact native source parity and
mirrored valid/mutated coverage. Persisted primary/canonical manifests,
activation metadata, and assets now use bounded `limit + 1` readers on
both platforms, with mirrored oversized-file faults and stable errors. A
fresh exact-app rerun, generated mutation, and the other listed
boundaries remain.
both platforms, with mirrored oversized-file faults and stable errors.
Every cache read also rejects outside-root files, file or ancestor
symlinks, directories, and missing files before opening bytes. A fresh
exact-app rerun, generated mutation, cache mutation/cleanup faults, and
the other listed boundaries remain.
- [ ] Attempt cross-host, cross-build, cross-workspace, cross-session, replay,
reconnect, process-loss, and host-removal races.
- [ ] Verify no credential or privileged host identity reaches URLs, DOM state,
@@ -0,0 +1,39 @@
package expo.modules.mobilewebshell
import java.io.File
import java.io.FileInputStream
internal fun readMobileWebFile(
file: File,
withinRoot: File,
byteLimit: Int,
overflowCode: String
): ByteArray {
try {
requireMobileWebRegularFile(file, withinRoot, overflowCode)
val bytes = ByteArray(byteLimit + 1)
var offset = 0
FileInputStream(file).use { input ->
while (offset < bytes.size) {
val read = input.read(bytes, offset, bytes.size - offset)
if (read <= 0) break
offset += read
}
}
require(offset <= byteLimit) { overflowCode }
return bytes.copyOf(offset)
} catch (error: Exception) {
if (error is IllegalArgumentException && error.message == overflowCode) throw error
throw IllegalArgumentException(overflowCode)
}
}
private fun requireMobileWebRegularFile(file: File, root: File, errorCode: String) {
val rootPath = root.absoluteFile.path.trimEnd(File.separatorChar)
val prefix = rootPath + File.separator
val filePath = file.absoluteFile.path
require(filePath.startsWith(prefix)) { errorCode }
val relativePath = filePath.removePrefix(prefix)
val expectedPath = File(root.canonicalFile, relativePath).absoluteFile.path
require(file.canonicalFile.path == expectedPath && file.isFile) { errorCode }
}
@@ -5,7 +5,6 @@ import android.system.Os
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.security.MessageDigest
@@ -168,6 +167,7 @@ internal class MobileWebPackageStore internal constructor(
val file = assetFile(stage.root, path)
val bytes = readMobileWebFile(
file,
cacheRoot,
asset.byteLength,
"mobile_web_stage_asset_invalid"
)
@@ -314,7 +314,7 @@ internal class MobileWebPackageStore internal constructor(
?: throw IllegalArgumentException("mobile_web_asset_unavailable")
val file = assetFile(session.root, asset.path)
val bytes = try {
readMobileWebFile(file, asset.byteLength, "mobile_web_generation_invalid")
readMobileWebFile(file, cacheRoot, asset.byteLength, "mobile_web_generation_invalid")
} catch (_: Exception) {
throw IllegalArgumentException("mobile_web_generation_invalid")
}
@@ -429,6 +429,7 @@ internal class MobileWebPackageStore internal constructor(
val file = assetFile(root, asset.path)
val bytes = readMobileWebFile(
file,
cacheRoot,
asset.byteLength,
"mobile_web_generation_invalid"
)
@@ -442,11 +443,13 @@ internal class MobileWebPackageStore internal constructor(
val manifest = parseManifest(
readMobileWebFile(
File(root, "manifest.json"),
cacheRoot,
MANIFEST_JSON_BYTE_LIMIT,
"mobile_web_generation_invalid"
).toString(Charsets.UTF_8),
readMobileWebFile(
File(root, "canonical-manifest.json"),
cacheRoot,
MANIFEST_JSON_BYTE_LIMIT,
"mobile_web_generation_invalid"
).toString(Charsets.UTF_8)
@@ -461,6 +464,7 @@ internal class MobileWebPackageStore internal constructor(
parseMobileWebActivationRecord(
readMobileWebFile(
File(hostRoot, "activation.json"),
cacheRoot,
ACTIVATION_JSON_BYTE_LIMIT,
"mobile_web_activation_invalid"
).toString(Charsets.UTF_8)
@@ -693,24 +697,6 @@ private fun storageException(error: Exception, fallback: String): IllegalArgumen
)
}
private fun readMobileWebFile(
file: File,
byteLimit: Int,
overflowCode: String
): ByteArray {
val bytes = ByteArray(byteLimit + 1)
var offset = 0
FileInputStream(file).use { input ->
while (offset < bytes.size) {
val read = input.read(bytes, offset, bytes.size - offset)
if (read <= 0) break
offset += read
}
}
require(offset <= byteLimit) { overflowCode }
return bytes.copyOf(offset)
}
private fun isStorageUnavailable(error: Throwable?): Boolean {
if (error == null) return false
if (error is IOException && error.message?.contains("ENOSPC", ignoreCase = true) == true) {
@@ -0,0 +1,49 @@
package expo.modules.mobilewebshell
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class MobileWebCacheFileBoundaryTest {
@get:Rule
val temporary = TemporaryFolder()
@Test
fun rejectsFilesOutsideTheCacheAndSymbolicLinks() {
val root = temporary.newFolder("cache")
val regular = File(root, "regular").apply { writeText("valid") }
assertArrayEquals(
"valid".toByteArray(),
readMobileWebFile(regular, root, 5, "mobile_web_generation_invalid")
)
val outside = temporary.newFile("outside").apply { writeText("valid") }
assertRejected(outside, root)
val fileLink = File(root, "file-link")
Files.createSymbolicLink(fileLink.toPath(), outside.toPath())
assertRejected(fileLink, root)
val externalDirectory = temporary.newFolder("external-dir")
File(externalDirectory, "asset").writeText("valid")
val directoryLink = File(root, "directory-link")
Files.createSymbolicLink(directoryLink.toPath(), externalDirectory.toPath())
assertRejected(File(directoryLink, "asset"), root)
val directory = File(root, "directory").apply { mkdir() }
assertRejected(directory, root)
assertRejected(File(root, "missing"), root)
}
private fun assertRejected(file: File, root: File) {
val error = assertThrows(IllegalArgumentException::class.java) {
readMobileWebFile(file, root, 5, "mobile_web_generation_invalid")
}
assertEquals("mobile_web_generation_invalid", error.message)
}
}
@@ -0,0 +1,66 @@
import Foundation
enum MobileWebCacheFileBoundaryTests {
static func run(root: URL) throws {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let regular = root.appendingPathComponent("regular")
try Data("valid".utf8).write(to: regular)
let bytes = try readMobileWebFile(
regular,
within: root,
byteLimit: 5,
overflowCode: "mobile_web_generation_invalid"
)
precondition(bytes == Data("valid".utf8))
let outside = root.deletingLastPathComponent().appendingPathComponent("outside")
try Data("valid".utf8).write(to: outside)
defer { try? FileManager.default.removeItem(at: outside) }
try assertRejected(outside, within: root)
let fileLink = root.appendingPathComponent("file-link")
try FileManager.default.createSymbolicLink(at: fileLink, withDestinationURL: outside)
try assertRejected(fileLink, within: root)
let externalDirectory = root.deletingLastPathComponent().appendingPathComponent("external-dir")
try FileManager.default.createDirectory(
at: externalDirectory,
withIntermediateDirectories: true
)
defer { try? FileManager.default.removeItem(at: externalDirectory) }
try Data("valid".utf8).write(to: externalDirectory.appendingPathComponent("asset"))
let directoryLink = root.appendingPathComponent("directory-link")
try FileManager.default.createSymbolicLink(
at: directoryLink,
withDestinationURL: externalDirectory
)
try assertRejected(directoryLink.appendingPathComponent("asset"), within: root)
let directory = root.appendingPathComponent("directory")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try assertRejected(directory, within: root)
try assertRejected(root.appendingPathComponent("missing"), within: root)
}
private static func assertRejected(_ file: URL, within root: URL) throws {
precondition(
throwsCode("mobile_web_generation_invalid") {
_ = try readMobileWebFile(
file,
within: root,
byteLimit: 5,
overflowCode: "mobile_web_generation_invalid"
)
}
)
}
private static func throwsCode(_ code: String, _ body: () throws -> Void) -> Bool {
do {
try body()
return false
} catch {
return error.localizedDescription == code
}
}
}
@@ -28,6 +28,9 @@ enum MobileWebPackageStoreTests {
try MobileWebActivationMetadataTests.run(
root: root.appendingPathComponent("activation-types")
)
try MobileWebCacheFileBoundaryTests.run(
root: root.appendingPathComponent("cache-file-boundary")
)
try rejectsLowStorage(root: root.appendingPathComponent("low-storage"))
try evictsUnprotectedGeneration(root: root.appendingPathComponent("eviction"))
try evictsAnotherHostForGlobalQuota(root: root.appendingPathComponent("global-eviction"))
@@ -0,0 +1,58 @@
import Foundation
func readMobileWebFile(
_ url: URL,
within cacheRoot: URL,
byteLimit: Int,
overflowCode: String
) throws -> Data {
do {
try requireMobileWebRegularFile(url, within: cacheRoot, errorCode: overflowCode)
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
data.reserveCapacity(byteLimit + 1)
while data.count <= byteLimit {
let remaining = byteLimit + 1 - data.count
guard
remaining > 0,
let chunk = try handle.read(upToCount: min(64 * 1024, remaining)),
!chunk.isEmpty
else {
break
}
data.append(chunk)
}
guard data.count <= byteLimit else {
throw MobileWebStoreError(overflowCode)
}
return data
} catch let error as MobileWebStoreError {
throw error
} catch {
throw MobileWebStoreError(overflowCode)
}
}
private func requireMobileWebRegularFile(
_ url: URL,
within cacheRoot: URL,
errorCode: String
) throws {
let root = cacheRoot.standardizedFileURL
let file = url.standardizedFileURL
let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/"
guard file.path.hasPrefix(prefix) else {
throw MobileWebStoreError(errorCode)
}
let relativePath = String(file.path.dropFirst(prefix.count))
let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL
let expected = relativePath.split(separator: "/").reduce(resolvedRoot) { parent, component in
parent.appendingPathComponent(String(component), isDirectory: false)
}
let resolvedFile = file.resolvingSymlinksInPath().standardizedFileURL
let values = try resolvedFile.resourceValues(forKeys: [.isRegularFileKey])
guard resolvedFile.path == expected.standardizedFileURL.path, values.isRegularFile == true else {
throw MobileWebStoreError(errorCode)
}
}
@@ -219,6 +219,7 @@ final class MobileWebPackageStore {
let file = assetUrl(root: stage.root, path: path)
let bytes = try readMobileWebFile(
file,
within: try cacheRoot(),
byteLimit: asset.byteLength,
overflowCode: "mobile_web_stage_asset_invalid"
)
@@ -443,6 +444,7 @@ final class MobileWebPackageStore {
do {
data = try readMobileWebFile(
file,
within: try cacheRoot(),
byteLimit: asset.byteLength,
overflowCode: "mobile_web_generation_invalid"
)
@@ -598,6 +600,7 @@ final class MobileWebPackageStore {
for asset in manifest.assets.values {
let bytes = try readMobileWebFile(
assetUrl(root: root, path: asset.path),
within: try cacheRoot(),
byteLimit: asset.byteLength,
overflowCode: "mobile_web_generation_invalid"
)
@@ -611,11 +614,13 @@ final class MobileWebPackageStore {
do {
let manifestData = try readMobileWebFile(
root.appendingPathComponent("manifest.json"),
within: try cacheRoot(),
byteLimit: manifestJsonByteLimit,
overflowCode: "mobile_web_generation_invalid"
)
let canonicalManifestData = try readMobileWebFile(
root.appendingPathComponent("canonical-manifest.json"),
within: try cacheRoot(),
byteLimit: manifestJsonByteLimit,
overflowCode: "mobile_web_generation_invalid"
)
@@ -640,6 +645,7 @@ final class MobileWebPackageStore {
do {
let data = try readMobileWebFile(
hostRoot.appendingPathComponent("activation.json"),
within: try cacheRoot(),
byteLimit: activationJsonByteLimit,
overflowCode: "mobile_web_activation_invalid"
)
@@ -895,7 +901,7 @@ final class MobileWebPackageStore {
let sharedMobileWebPackageStore = MobileWebPackageStore()
#endif
private struct MobileWebStoreError: LocalizedError {
struct MobileWebStoreError: LocalizedError {
let code: String
var errorDescription: String? { code }
@@ -911,32 +917,6 @@ private func storageError(_ error: Error, fallback: String) -> MobileWebStoreErr
return MobileWebStoreError(code)
}
private func readMobileWebFile(
_ url: URL,
byteLimit: Int,
overflowCode: String
) throws -> Data {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
data.reserveCapacity(byteLimit + 1)
while data.count <= byteLimit {
let remaining = byteLimit + 1 - data.count
guard
remaining > 0,
let chunk = try handle.read(upToCount: min(64 * 1024, remaining)),
!chunk.isEmpty
else {
break
}
data.append(chunk)
}
guard data.count <= byteLimit else {
throw MobileWebStoreError(overflowCode)
}
return data
}
private func mobileWebAvailableStorageBytes(_ root: URL) -> Int64? {
try? root.resourceValues(
forKeys: [.volumeAvailableCapacityForImportantUsageKey]
@@ -20,11 +20,16 @@ try {
'-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/MobileWebCacheFileBoundary.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/MobileWebCacheFileBoundaryTests.swift'
),
join(
mobileRoot,
'packages/expo-mobile-web-shell/ios-tests/MobileWebPackageStoreProcessInterruptionTests.swift'