fix(mobile-web-shell): make the Android module compile and hold its stage boundary

The Kotlin sources for the hybrid WebView shell had never been built by any
workflow, so five compile errors and one boundary escape shipped unnoticed:

- MobileWebShellView shadowed ExpoView.appContext and used `return` inside
  three expression-bodied functions.
- MobileWebBridgeDocumentUrl passed a java.net.URI to the android.net.Uri
  origin check; it now compares against an Android-free host projection so the
  JVM unit test can exercise it.
- beginStage called mkdirs() before checking its ancestors, creating the stage
  through a symlinked `staging` directory and leaving it behind when the later
  check rejected it (MobileWebCacheWriteBoundaryTest).

43 tests now pass under :orca-expo-mobile-web-shell:testDebugUnitTest.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-01 16:36:02 -04:00
parent 0f029172b8
commit 7ae41c4f1f
4 changed files with 32 additions and 19 deletions
@@ -9,7 +9,7 @@ internal fun isAllowedMobileWebBridgeDocumentUrl(value: String, sessionId: Strin
val url = URI(value)
value.length <= MOBILE_WEB_DOCUMENT_URL_LIMIT &&
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
isMobileWebOriginForSession(url, sessionId) &&
url.host == mobileWebOriginHostForSession(sessionId) &&
url.port == -1 &&
url.userInfo == null &&
url.fragment == sessionId
@@ -14,6 +14,10 @@ internal fun mobileWebOriginForSession(sessionId: String): String {
internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId))
/** Android-free host projection so JVM unit tests can check origins without android.net.Uri. */
internal fun mobileWebOriginHostForSession(sessionId: String): String =
mobileWebOriginForSession(sessionId).substringAfter("://")
internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean =
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
url.host == mobileWebOriginUriForSession(sessionId).host &&
@@ -103,8 +103,14 @@ internal class MobileWebPackageStore internal constructor(
manifestJson.toByteArray(Charsets.UTF_8).size +
canonicalManifestJson.toByteArray(Charsets.UTF_8).size
reserveCacheCapacity(hostKey, reservedByteLength)
val stageRoot = File(cacheRoot, "$hostKey/staging/$stageId")
val hostRoot = File(cacheRoot, hostKey)
val stagingRoot = File(hostRoot, "staging")
val stageRoot = File(stagingRoot, stageId)
try {
// mkdirs() would create the stage *through* a symlinked ancestor before any later check
// could reject it, so the ancestors are validated first.
require(isMobileWebUnlinkedPath(hostRoot, cacheRoot)) { "mobile_web_stage_create_failed" }
require(isMobileWebUnlinkedPath(stagingRoot, cacheRoot)) { "mobile_web_stage_create_failed" }
require(stageRoot.mkdirs()) { "mobile_web_stage_create_failed" }
require(isMobileWebUnlinkedPath(stageRoot, cacheRoot)) {
"mobile_web_stage_create_failed"
@@ -64,7 +64,7 @@ private val MOBILE_WEB_MERMAID_FRAME_CSP = listOf(
@SuppressLint("ViewConstructor", "SetJavaScriptEnabled")
internal class MobileWebShellView(
context: Context,
private val appContext: AppContext
appContext: AppContext
) : ExpoView(context, appContext) {
private val onBridgeMessage by EventDispatcher<Map<String, Any>>()
private val onNavigationBlocked by EventDispatcher<Map<String, Any>>()
@@ -345,32 +345,35 @@ internal class MobileWebShellView(
)
}
private fun isAllowedDocumentUrl(url: Uri): Boolean =
activeSessionId != null &&
isMobileWebOriginForSession(url, activeSessionId ?: return false) &&
private fun isAllowedDocumentUrl(url: Uri): Boolean {
val sessionId = activeSessionId ?: return false
return isMobileWebOriginForSession(url, sessionId) &&
url.path == "/" &&
url.encodedPath == "/" &&
url.query == null &&
url.fragment == activeSessionId &&
url.toString().length <= 8 * 1024
url.fragment == sessionId &&
url.toString().length <= 8 * 1024
}
private fun isAllowedDocumentRequestUrl(url: Uri): Boolean =
activeSessionId != null &&
isMobileWebOriginForSession(url, activeSessionId ?: return false) &&
url.path == "/" &&
url.encodedPath == "/" &&
url.query == null &&
(url.fragment == null || url.fragment == activeSessionId) &&
url.toString().length <= 8 * 1024
private fun isAllowedDocumentRequestUrl(url: Uri): Boolean {
val sessionId = activeSessionId ?: return false
return isMobileWebOriginForSession(url, sessionId) &&
url.path == "/" &&
url.encodedPath == "/" &&
url.query == null &&
(url.fragment == null || url.fragment == sessionId) &&
url.toString().length <= 8 * 1024
}
private fun isAllowedEmbeddedDocumentUrl(url: Uri): Boolean =
activeSessionId != null &&
isMobileWebOriginForSession(url, activeSessionId ?: return false) &&
private fun isAllowedEmbeddedDocumentUrl(url: Uri): Boolean {
val sessionId = activeSessionId ?: return false
return isMobileWebOriginForSession(url, sessionId) &&
url.path == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
url.encodedPath == "/$MOBILE_WEB_MERMAID_FRAME_PATH" &&
url.query == null &&
url.fragment == null &&
url.toString().length <= 8 * 1024
}
private fun blockedResponse(): WebResourceResponse = WebResourceResponse(
"text/plain",