From 7ae41c4f1fbe21e8ea3982a2de321347f9f5521f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 1 Sep 2026 16:36:02 -0400 Subject: [PATCH] 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 --- .../MobileWebBridgeDocumentUrl.kt | 2 +- .../modules/mobilewebshell/MobileWebOrigin.kt | 4 ++ .../mobilewebshell/MobileWebPackageStore.kt | 8 +++- .../mobilewebshell/MobileWebShellView.kt | 37 ++++++++++--------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt index 96b8bc667b5..be7b1a3ede2 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt @@ -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 diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt index 0e1de007f75..9d457f4c10a 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt @@ -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 && 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 7db1098c604..abf26135866 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 @@ -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" diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt index 482754a6dea..8cfe757c49c 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt @@ -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>() private val onNavigationBlocked by EventDispatcher>() @@ -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",