fix(mobile): make the Android hosted origin a canonical host label

The Android private origin's host label is the session id's prefix, but the
ids were base64url. `https` is a special scheme, so Chromium ASCII-lowercases
the host of every URL it loads and reports back, while `Uri.parse` keeps the
mixed case the shell derived: `serveRequest`'s origin check rejected the main
document and answered 403, which Android renders as
`net::ERR_HTTP_RESPONSE_CODE_FAILURE`. `java.net.URI.getHost()` is also null
for a label holding `_`, so the same ids dropped every bridge message.

Session ids now come from a lowercase base32 alphabet,
`mobileWebOriginForSession` rejects anything a URL host cannot carry, and host
comparisons are case-insensitive. A failed main-frame document no longer stops
at Chromium's error page: the shell hides the WebView and reports `failed` with
a reason the React Native shell shows.

iOS uses a custom scheme, whose opaque host preserves case and `_`, which is
why only the Android lane saw this.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-03 03:48:08 -04:00
parent afe62902a5
commit 7baa64d36b
16 changed files with 282 additions and 24 deletions
@@ -152,6 +152,17 @@ external-link authority.
- Top-level navigation is restricted to the active document. External
navigation, popups, downloads, workers, and arbitrary bridge origins fail
closed.
- The Android session id *is* the private origin's host label, so it is drawn
from a canonical hostname alphabet (lowercase base32). `https` is a special
scheme: Chromium ASCII-lowercases the host of every URL it loads and reports
back, and `java.net.URI.getHost()` is null for a label holding `_`. A
base64url id therefore lost every asset request to a 403 and dropped every
bridge message (`MobileWebOrigin.kt`, `MobileWebSessionIdentifier.kt`). iOS
uses a custom scheme, whose opaque host preserves both, which is why the iOS
lane never saw it.
- A failed main-frame document does not stop at Chromium's error page: the
Android shell hides the WebView and reports `onLoadState` `failed` with a
reason code the React Native shell shows instead.
### Capability bridge
+5
View File
@@ -18,6 +18,7 @@ import { MobileWebHealthDeadline } from '../src/mobile-web/mobile-web-health-dea
import { useMobileWebAlertSafePackageSession } from '../src/mobile-web/use-mobile-web-alert-safe-package-session'
import { createMobileWebNativeCapabilityAuthority } from '../src/mobile-web/mobile-web-native-capability-authority'
import { MobileWebHybridShellPresentation } from '../src/mobile-web/MobileWebHybridShellPresentation'
import { mobileWebShellLoadFailureWarning } from '../src/mobile-web/mobile-web-shell-load-failure-warning'
import { useMobileWebNavigationIntentHandoff } from '../src/mobile-web/use-mobile-web-navigation-intent-handoff'
import { useMobileWebColdResumeRoute } from '../src/mobile-web/use-mobile-web-cold-resume-route'
import { mobileWebBridgeConnectionState } from '../src/mobile-web/mobile-web-bridge-connection-state'
@@ -384,6 +385,10 @@ export default function HybridScreen() {
hardwareBackHandoff.resetPage()
void postInit()
}}
onLoadFailed={(reason) => {
healthDeadlineRef.current.clear()
showWarning(mobileWebShellLoadFailureWarning(reason))
}}
onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')}
onProcessTerminated={(sessionId) => {
hardwareBackHandoff.resetPage()
@@ -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 &&
url.host == mobileWebOriginHostForSession(sessionId) &&
isMobileWebOriginHostForSession(url.host, sessionId) &&
url.port == -1 &&
url.userInfo == null &&
url.fragment == sessionId
@@ -3,13 +3,26 @@ package expo.modules.mobilewebshell
import android.net.Uri
private const val MOBILE_WEB_ORIGIN_SUFFIX = ".orca-mobile-web.invalid"
private const val MOBILE_WEB_ORIGIN_LABEL_LIMIT = 32
/** Derives a per-session origin so WebView cookies/storage cannot cross hosts. */
/**
* Derives a per-session origin so WebView cookies/storage cannot cross hosts.
*
* The id's prefix *is* the host label, so it must already be a canonical hostname label: `https` is
* a special scheme, so Chromium ASCII-lowercases the host of every URL it loads and reports back,
* and `java.net.URI.getHost()` returns null for a label holding anything outside `[a-z0-9-]`.
* Rejecting a non-canonical id here fails the session open loudly instead of leaving every asset
* request to 403 behind Chromium's own error page.
*/
internal fun mobileWebOriginForSession(sessionId: String): String {
require(sessionId.isNotEmpty() && sessionId.length <= 128 && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) {
require(
sessionId.isNotEmpty() &&
sessionId.length <= 128 &&
sessionId.all { it in 'a'..'z' || it in '0'..'9' }
) {
"mobile_web_session_id_invalid"
}
return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(32)}$MOBILE_WEB_ORIGIN_SUFFIX"
return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(MOBILE_WEB_ORIGIN_LABEL_LIMIT)}$MOBILE_WEB_ORIGIN_SUFFIX"
}
internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId))
@@ -18,8 +31,12 @@ internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mo
internal fun mobileWebOriginHostForSession(sessionId: String): String =
mobileWebOriginForSession(sessionId).substringAfter("://")
/** Hosts are case-insensitive, so a non-canonical parser must still bind to the active session. */
internal fun isMobileWebOriginHostForSession(host: String?, sessionId: String): Boolean =
host != null && host.equals(mobileWebOriginHostForSession(sessionId), ignoreCase = true)
internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean =
url.scheme == MOBILE_WEB_ORIGIN_SCHEME &&
url.host == mobileWebOriginUriForSession(sessionId).host &&
isMobileWebOriginHostForSession(url.host, sessionId) &&
url.port == -1 &&
url.userInfo == null
@@ -9,7 +9,6 @@ import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
private const val CHUNK_BYTE_LIMIT = 48 * 1024
private const val CHUNK_BASE64_CHARACTER_LIMIT = ((CHUNK_BYTE_LIMIT + 2) / 3) * 4
@@ -676,14 +675,7 @@ internal class MobileWebPackageStore internal constructor(
"url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId"
)
private fun randomIdentifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return encodeBase64(bytes)
.replace('+', '-')
.replace('/', '_')
.trimEnd('=')
}
private fun randomIdentifier(): String = mobileWebRandomIdentifier()
private fun requireStage(stageId: String): MobileWebStageRecord =
stages[stageId] ?: throw IllegalArgumentException("mobile_web_stage_unknown")
@@ -0,0 +1,36 @@
package expo.modules.mobilewebshell
import java.security.SecureRandom
private const val MOBILE_WEB_IDENTIFIER_BYTE_LENGTH = 32
private const val MOBILE_WEB_IDENTIFIER_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
/**
* Session ids become the WebView origin's host label, so they are drawn from a canonical hostname
* alphabet: base64url's `_` makes `URI.getHost()` null and its uppercase letters do not survive
* Chromium's host canonicalization.
*/
internal fun mobileWebRandomIdentifier(random: SecureRandom = SecureRandom()): String {
val bytes = ByteArray(MOBILE_WEB_IDENTIFIER_BYTE_LENGTH)
random.nextBytes(bytes)
return encodeMobileWebIdentifierAlphabet(bytes)
}
/** RFC 4648 base32 over a lowercase alphabet, without padding. */
private fun encodeMobileWebIdentifierAlphabet(bytes: ByteArray): String {
val encoded = StringBuilder()
var buffer = 0
var bufferedBits = 0
for (byte in bytes) {
buffer = (buffer shl 8) or (byte.toInt() and 0xff)
bufferedBits += 8
while (bufferedBits >= 5) {
bufferedBits -= 5
encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shr bufferedBits) and 0x1f])
}
}
if (bufferedBits > 0) {
encoded.append(MOBILE_WEB_IDENTIFIER_ALPHABET[(buffer shl (5 - bufferedBits)) and 0x1f])
}
return encoded.toString()
}
@@ -263,7 +263,14 @@ internal class MobileWebShellView(
private inner class LockedWebViewClient : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse {
return runCatching { serveRequest(request) }.getOrElse { blockedResponse() }
return runCatching { serveRequest(request) }.getOrElse { error ->
if (request.isForMainFrame) {
// Chromium turns a non-2xx main-frame response into its own error page, so the shell has
// to name the reason itself before that page replaces the document.
reportDocumentFailure(error.message ?: "mobile_web_document_unavailable")
}
blockedResponse()
}
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
@@ -280,6 +287,7 @@ internal class MobileWebShellView(
override fun onPageFinished(view: WebView, url: String) {
if (isAllowedDocumentUrl(Uri.parse(url))) {
documentLoaded = true
view.visibility = View.VISIBLE
view.clearHistory()
onLoadState(mapOf("state" to "loaded"))
}
@@ -291,8 +299,17 @@ internal class MobileWebShellView(
error: android.webkit.WebResourceError
) {
if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) {
documentLoaded = false
onLoadState(mapOf("state" to "failed"))
reportDocumentFailure("mobile_web_document_load_error_${error.errorCode}")
}
}
override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
errorResponse: WebResourceResponse
) {
if (request.isForMainFrame && isAllowedDocumentRequestUrl(request.url)) {
reportDocumentFailure("mobile_web_document_http_${errorResponse.statusCode}")
}
}
@@ -323,6 +340,15 @@ internal class MobileWebShellView(
}
}
/** Hides the Chromium error page and hands the RN shell a reason it can show instead. */
private fun reportDocumentFailure(reason: String) {
post {
documentLoaded = false
webView.visibility = View.INVISIBLE
onLoadState(mapOf("state" to "failed", "reason" to reason.take(128)))
}
}
private fun serveRequest(request: WebResourceRequest): WebResourceResponse {
val url = request.url
val sessionId = activeSessionId
@@ -5,7 +5,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class MobileWebBridgeDocumentUrlTest {
private val sessionId = "S".repeat(43)
private val sessionId = "s5i5ifxeticyxgmyvxhtvzktwwbo5o7h5uzaapc0w"
private val origin = mobileWebOriginForSession(sessionId)
@Test
@@ -39,7 +39,7 @@ class MobileWebBridgeDocumentUrlTest {
"$origin:443/#$sessionId",
"https://orca-mobile-web.invalid.evil.test/#$sessionId",
"$origin/",
"${mobileWebOriginForSession("T".repeat(43))}/#$sessionId",
"${mobileWebOriginForSession("t".repeat(43))}/#$sessionId",
"$origin/${"a".repeat(8 * 1024)}#$sessionId",
"not a url"
)
@@ -0,0 +1,64 @@
package expo.modules.mobilewebshell
import java.net.URI
import java.security.SecureRandom
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The session id's prefix is the private origin's host label. Chromium ASCII-lowercases the host of
* every `https` URL it loads and reports back, and `java.net.URI.getHost()` is null for a label
* holding `_`, so a base64url id silently loses the document (403) and the bridge (dropped
* messages).
*/
class MobileWebOriginTest {
@Test
fun `generated session ids survive host parsing unchanged`() {
val random = SecureRandom.getInstance("SHA1PRNG").apply { setSeed(ByteArray(16)) }
repeat(64) {
val sessionId = mobileWebRandomIdentifier(random)
val host = mobileWebOriginHostForSession(sessionId)
assertEquals(host, host.lowercase())
assertEquals(host, URI(mobileWebOriginForSession(sessionId)).host)
assertTrue(sessionId, isMobileWebOriginHostForSession(URI("https://$host/").host, sessionId))
assertTrue(
sessionId,
isAllowedMobileWebBridgeDocumentUrl(
"${mobileWebOriginForSession(sessionId)}/#$sessionId",
sessionId
)
)
}
}
@Test
fun `rejects session ids a URL host cannot carry`() {
val rejected = listOf(
"47Im-Tb2rq2Y5jz235dEGMcvyQSk5p17H5UZaAPc",
"47im-tb2_rq2y5jz235degmcvyqsk5p17h5uzaapc",
"47im-tb2-rq2y5jz235degmcvyqsk5p17h5uzaapc",
""
)
for (sessionId in rejected) {
assertEquals(
sessionId,
"mobile_web_session_id_invalid",
runCatching { mobileWebOriginForSession(sessionId) }.exceptionOrNull()?.message
)
}
}
@Test
fun `keeps distinct sessions on distinct origins`() {
val sessionId = mobileWebRandomIdentifier()
val other = mobileWebRandomIdentifier()
assertFalse(mobileWebOriginForSession(sessionId) == mobileWebOriginForSession(other))
assertFalse(isMobileWebOriginHostForSession(mobileWebOriginHostForSession(other), sessionId))
}
}
@@ -37,7 +37,7 @@ class MobileWebPackageStoreGeneratedMutationTest {
}
val stageId = store.beginStage("generated-host", fixture.manifest, fixture.canonical)
assertTrue(stageId.matches(Regex("^[A-Za-z0-9_-]{43}$")))
assertTrue(stageId.matches(Regex("^[a-z2-7]{52}$")))
val encoded = Base64.getEncoder().encodeToString(fixture.bytes)
repeat(256) { iteration ->
val characters = encoded.toCharArray()
@@ -31,8 +31,9 @@ class MobileWebPackageStoreTest {
val asset = store.readAsset(sessionId, "index.html")
assertEquals(fixture.buildId, session["buildId"])
assertEquals(43, sessionId.length)
assertEquals(true, Regex("[A-Za-z0-9_-]{43}").matches(sessionId))
assertEquals(52, sessionId.length)
assertEquals(true, Regex("[a-z2-7]{52}").matches(sessionId))
assertEquals("${mobileWebOriginForSession(sessionId)}/#$sessionId", session["url"])
assertEquals("text/html; charset=utf-8", asset.contentType)
assertArrayEquals(fixture.bytes, asset.bytes)
val error = assertThrows(IllegalArgumentException::class.java) {
@@ -24,7 +24,7 @@ export type MobileWebShellViewProps = ViewProps & {
onBridgeMessage?: MobileWebShellEvent<{ data: string }>
onNavigationBlocked?: MobileWebShellEvent<{ url: string }>
onProcessTerminated?: MobileWebShellEvent<{ sessionId: string }>
onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed' }>
onLoadState?: MobileWebShellEvent<{ state: 'loading' | 'loaded' | 'failed'; reason?: string }>
}
const NativeMobileWebShellView: ComponentType<
@@ -35,6 +35,7 @@ type MobileWebHybridShellPresentationProps = {
onRecoveryFailure: () => void
onBridgeMessage: (message: string) => void
onPageLoaded: () => void
onLoadFailed: (reason: string | undefined) => void
onNavigationBlocked: () => void
onProcessTerminated: (sessionId: string) => void
}
@@ -56,6 +57,7 @@ export function MobileWebHybridShellPresentation({
onRecoveryFailure,
onBridgeMessage,
onPageLoaded,
onLoadFailed,
onNavigationBlocked,
onProcessTerminated
}: MobileWebHybridShellPresentationProps) {
@@ -127,6 +129,10 @@ export function MobileWebHybridShellPresentation({
onLoadState={(event) => {
if (event.nativeEvent.state === 'loaded') {
onPageLoaded()
return
}
if (event.nativeEvent.state === 'failed') {
onLoadFailed(event.nativeEvent.reason)
}
}}
onNavigationBlocked={onNavigationBlocked}
@@ -71,7 +71,7 @@ describe('mobile web native bridge transport', () => {
'!isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId)'
)
expect(androidBridgeUrlSource).toContain('url.scheme == MOBILE_WEB_ORIGIN_SCHEME')
expect(androidBridgeUrlSource).toContain('url.host == mobileWebOriginHostForSession(sessionId)')
expect(androidBridgeUrlSource).toContain('isMobileWebOriginHostForSession(url.host, sessionId)')
expect(androidBridgeUrlSource).toContain('url.fragment == sessionId')
expect(androidBridgeUrlSource).toContain('url.userInfo == null')
expect(androidSource).toContain('request.isForMainFrame && isAllowedDocumentUrl(url)')
@@ -0,0 +1,10 @@
/** Turns a native shell document-load failure code into text the hosted shell can show. */
export function mobileWebShellLoadFailureWarning(reason: string | undefined): string {
if (reason === 'mobile_web_generation_invalid') {
return 'This desktop’s cached workspace interface failed verification and was not displayed.'
}
if (reason && reason !== 'mobile_web_document_unavailable') {
return `The workspace interface could not be displayed (${reason}).`
}
return 'The workspace interface could not be displayed.'
}
@@ -0,0 +1,90 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileWebHybridShellPresentation } from './MobileWebHybridShellPresentation'
import { mobileWebShellLoadFailureWarning } from './mobile-web-shell-load-failure-warning'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
StyleSheet: { create: (styles: Record<string, unknown>) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft',
MonitorSmartphone: 'MonitorSmartphone'
}))
vi.mock('@orca/expo-mobile-web-shell', () => ({
MobileWebShellView: 'MobileWebShellView'
}))
const noop = () => {}
describe('hosted shell document load failures', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('hands the native failure reason to the shell instead of leaving the error page unexplained', () => {
const failures: (string | undefined)[] = []
const loaded = vi.fn()
act(() => {
renderer = create(
createElement(MobileWebHybridShellPresentation, {
viewRef: { current: null },
selectedHost: { id: 'host-1', name: 'Desk', publicKeyB64: 'k' } as never,
session: { sessionId: 'abc', buildId: 'build-1' } as never,
viewEpoch: 0,
packageLoading: false,
packageProgress: undefined,
packageWarning: undefined,
hostedViewActive: true,
onBack: noop,
onShowHosts: noop,
onRetryRecovery: noop,
onUsePrevious: noop,
onClearCache: noop,
onRecoveryFailure: noop,
onBridgeMessage: noop,
onPageLoaded: loaded,
onLoadFailed: (reason) => failures.push(reason),
onNavigationBlocked: noop,
onProcessTerminated: noop
})
)
})
const shell = renderer!.root.findByType('MobileWebShellView' as never)
act(() => {
shell.props.onLoadState({
nativeEvent: { state: 'failed', reason: 'mobile_web_document_http_403' }
})
})
expect(failures).toEqual(['mobile_web_document_http_403'])
expect(loaded).not.toHaveBeenCalled()
})
it('names the failure in copy the hosted shell can show', () => {
expect(mobileWebShellLoadFailureWarning('mobile_web_document_http_403')).toContain(
'mobile_web_document_http_403'
)
expect(mobileWebShellLoadFailureWarning('mobile_web_generation_invalid')).toContain(
'failed verification'
)
expect(mobileWebShellLoadFailureWarning(undefined)).toBe(
'The workspace interface could not be displayed.'
)
})
})