From 6cec3543cc4f9b01749941deab5259bd8d0e6f0e Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Wed, 29 Jul 2026 19:45:49 +0800 Subject: [PATCH 1/4] fix(web): isolate untrusted remote content Restrict wallet bridges to the reviewed HTTPS origin and block mixed content. Reject unsafe app-card cover targets and avoid fetches during save or share. Leave the app-wide cleartext network policy unchanged. --- .../android/ui/conversation/holder/AppCard.kt | 12 +- .../ui/media/pager/MediaPagerActivity.kt | 10 +- .../one/mixin/android/ui/web/WebFragment.kt | 181 ++++++++++-------- .../java/one/mixin/android/vo/AppCardData.kt | 53 ++++- .../one/mixin/android/vo/AppCardDataTest.kt | 33 ++++ 5 files changed, 197 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/conversation/holder/AppCard.kt b/app/src/main/java/one/mixin/android/ui/conversation/holder/AppCard.kt index 7ef8d4a92e..35585ce8ed 100644 --- a/app/src/main/java/one/mixin/android/ui/conversation/holder/AppCard.kt +++ b/app/src/main/java/one/mixin/android/ui/conversation/holder/AppCard.kt @@ -55,6 +55,7 @@ import one.mixin.android.compose.CoilImage import one.mixin.android.compose.theme.MixinAppTheme import one.mixin.android.extension.defaultSharedPreferences import one.mixin.android.vo.AppCardData +import one.mixin.android.vo.safeAppCardImageUrl import java.util.regex.Pattern @OptIn(ExperimentalFoundationApi::class) @@ -92,9 +93,11 @@ fun AppCard( interactionSource = remember { MutableInteractionSource() }, onClick = contentClick, onLongClick = contentLongClick )) { - if (!appCardData.coverUrl.isNullOrBlank()) { + val mediaCoverUrl = appCardData.coverUrl.safeAppCardImageUrl() + val nestedCoverUrl = appCardData.cover?.url.safeAppCardImageUrl() + if (mediaCoverUrl != null) { CoilImage( - model = appCardData.coverUrl, + model = mediaCoverUrl, placeholder = R.drawable.bot_default, contentScale = ContentScale.Crop, modifier = Modifier @@ -118,9 +121,9 @@ fun AppCard( } ) ) - } else if (appCardData.cover != null) { + } else if (appCardData.cover != null && nestedCoverUrl != null) { CoilImage( - model = appCardData.cover.url, + model = nestedCoverUrl, placeholder = appCardData.cover.thumbnailDrawable, contentScale = ContentScale.Crop, modifier = Modifier @@ -386,7 +389,6 @@ fun ClickableTextWithUrlsAndBots( } } } - internal fun appCardMentionDisplayText( identityNumber: String, mentionUserMap: Map, diff --git a/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt b/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt index 3827c3728f..ff67e13042 100644 --- a/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/media/pager/MediaPagerActivity.kt @@ -43,8 +43,6 @@ import androidx.paging.PagedList import androidx.viewpager2.widget.ViewPager2 import coil3.annotation.ExperimentalCoilApi import coil3.imageLoader -import coil3.request.ImageRequest -import coil3.request.SuccessResult import com.uber.autodispose.autoDispose import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers @@ -470,12 +468,8 @@ class MediaPagerActivity : BaseActivity(), DismissFrameLayout.OnDismissListener, val coverUrl = item.appCardMediaCoverUrl() if (coverUrl != null) { return try { - val loader = imageLoader - val result = loader.execute(ImageRequest.Builder(this).data(coverUrl).build()) - if (result !is SuccessResult) { - null - } else { - loader.diskCache?.openSnapshot(coverUrl)?.data?.toFile() + imageLoader.diskCache?.openSnapshot(coverUrl)?.use { snapshot -> + snapshot.data.toFile() } } catch (e: Exception) { null diff --git a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt index d44a0b9072..03f8f7fe18 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt @@ -196,10 +196,29 @@ import org.json.JSONObject import timber.log.Timber import java.io.ByteArrayInputStream import java.net.URI -import java.net.URISyntaxException import java.util.Locale import javax.inject.Inject +internal fun secureWebOrigin(url: String?): String? { + if (url == null) return null + return try { + val uri = URI(url) + val host = uri.host ?: return null + if (!uri.scheme.equals("https", true)) return null + val port = if (uri.port == -1) 443 else uri.port + "https://${host.lowercase(Locale.US)}:$port" + } catch (_: Exception) { + null + } +} + +internal fun isTrustedWebUrl( + url: String?, + trustedOrigin: String?, +): Boolean { + return trustedOrigin != null && secureWebOrigin(url) == trustedOrigin +} + @AndroidEntryPoint class WebFragment : BaseFragment() { companion object { @@ -255,6 +274,9 @@ class WebFragment : BaseFragment() { private val injectable: Boolean by lazy { requireArguments().getBoolean(ARGS_INJECTABLE, true) } + private val trustedWebOrigin: String? by lazy { + secureWebOrigin(url) + } private val fixedTitle: String? by lazy { requireArguments().getString(ARGS_FIXED_TITLE) } @@ -556,7 +578,7 @@ class WebFragment : BaseFragment() { webView.settings.loadWithOverviewMode = true webView.settings.textZoom = 100 webView.settings.mixedContentMode = - WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + WebSettings.MIXED_CONTENT_NEVER_ALLOW webView.settings.mediaPlaybackRequiresUserGesture = false webView.settings.userAgentString = webView.settings.userAgentString + " Mixin/" + BuildConfig.VERSION_NAME + " GOOGLE_PAY_SUPPORTED" @@ -577,6 +599,7 @@ class WebFragment : BaseFragment() { this.parentFragmentManager, requireActivity().activityResultRegistry, injectable, + trustedWebOrigin, lifecycleScope, { url -> currentUrl = url @@ -915,6 +938,11 @@ class WebFragment : BaseFragment() { private fun loadWebView() { _binding?.let { binding -> + if (trustedWebOrigin == null) { + openInBrowser(url) + closeSelf() + return + } binding.pb.isVisible = false var immersive = false app?.capabilities?.let { @@ -965,66 +993,68 @@ class WebFragment : BaseFragment() { openInBrowser(url) }, ) - webAppInterface?.let { webView.addJavascriptInterface(it, "MixinContext") } - webView.addJavascriptInterface( - Web3Interface( - onWalletActionSuccessful = { e -> - lifecycleScope.launch { - webView.evaluateJavascript(e, Timber::d) - } - }, - onWalletActionError = { id, code, message -> - lifecycleScope.launch { - webView.evaluateJavascript(walletErrorScript(id, code, message)) {} - } - }, - onBrowserSign = { message -> - lifecycleScope.launch { - if (viewDestroyed()) return@launch - - showGasCheckAndBrowserBottomSheetDialogFragment( - requireActivity(), - message, - currentUrl = currentUrl, - currentTitle = currentTitle, - onReject = { - lifecycleScope.launch { - webView.evaluateJavascript( - walletErrorScript( - message.callbackId, - WalletErrorCode.USER_REJECTED_REQUEST, - "User rejected the request", - ), - ) {} + if (injectable) { + webAppInterface?.let { webView.addJavascriptInterface(it, "MixinContext") } + webView.addJavascriptInterface( + Web3Interface( + onWalletActionSuccessful = { e -> + lifecycleScope.launch { + webView.evaluateJavascript(e, Timber::d) + } + }, + onWalletActionError = { id, code, message -> + lifecycleScope.launch { + webView.evaluateJavascript(walletErrorScript(id, code, message)) {} + } + }, + onBrowserSign = { message -> + lifecycleScope.launch { + if (viewDestroyed()) return@launch + + showGasCheckAndBrowserBottomSheetDialogFragment( + requireActivity(), + message, + currentUrl = currentUrl, + currentTitle = currentTitle, + onReject = { + lifecycleScope.launch { + webView.evaluateJavascript( + walletErrorScript( + message.callbackId, + WalletErrorCode.USER_REJECTED_REQUEST, + "User rejected the request", + ), + ) {} + } + }, + onDone = { callback -> + lifecycleScope.launch { + if (callback != null) webView.evaluateJavascript(callback) {} + } + }, + ) + } + }, + onEmptyAddress = { network -> + lifecycleScope.launch { + if (viewDestroyed()) return@launch + if (network.equals("solana", true)) { + if (Web3Signer.solanaAddress.isEmpty()) { + toast(getString(R.string.not_support_network, network)) } - }, - onDone = { callback -> - lifecycleScope.launch { - if (callback != null) webView.evaluateJavascript(callback) {} + } else if (network.equals("ethereum", true)) { + if (Web3Signer.evmAddress.isEmpty()) { + toast(getString(R.string.not_support_network, network)) } - }, - ) - } - }, - onEmptyAddress = { network -> - lifecycleScope.launch { - if (viewDestroyed()) return@launch - if (network.equals("solana", true)) { - if (Web3Signer.solanaAddress.isEmpty()) { - toast(getString(R.string.not_support_network, network)) - } - } else if (network.equals("ethereum", true)) { - if (Web3Signer.evmAddress.isEmpty()) { - toast(getString(R.string.not_support_network, network)) + } else { + return@launch } - } else { - return@launch } - } - }, - ), - "_mw_", - ) + }, + ), + "_mw_", + ) + } val extraHeaders = HashMap() conversationId?.let { extraHeaders[Mixin_Conversation_ID_HEADER] = it @@ -1159,11 +1189,7 @@ class WebFragment : BaseFragment() { } val sameHost = - try { - Uri.parse(webView.url).host == Uri.parse(app?.homeUri ?: "").host - } catch (e: Exception) { - false - } + secureWebOrigin(webView.url) == secureWebOrigin(app?.homeUri) if (!sameHost) { webView.evaluateJavascript("$callbackFunction('[]')") {} return@launch @@ -1810,6 +1836,7 @@ class WebFragment : BaseFragment() { private val fragmentManager: FragmentManager, private val registry: ActivityResultRegistry, private val inject: Boolean = true, + private val trustedOrigin: String?, private val scope: CoroutineScope, private val onFinished: (url: String?) -> Unit, private val onWebpageLoaded: (title: String?, url: String?) -> Unit, @@ -1821,6 +1848,11 @@ class WebFragment : BaseFragment() { JsInjectorClient() } + private fun canLoadUrl(url: String?): Boolean { + val origin = secureWebOrigin(url) ?: return false + return !inject || origin == trustedOrigin + } + private fun closeWebContainer() { fragment.activity?.finish() } @@ -1832,6 +1864,11 @@ class WebFragment : BaseFragment() { ) { super.onPageStarted(view, url, favicon) view ?: return + if (!canLoadUrl(url)) { + view.stopLoading() + url?.let { context.openInBrowser(it) } + return + } view.clearCache(true) Timber.e("onPageStarted ${Web3Signer.currentChain.name}") if (!redirect && inject) { @@ -1902,6 +1939,11 @@ class WebFragment : BaseFragment() { } val url = request.url.toString() + if (url.isWebUrl() && !canLoadUrl(url)) { + context.openInBrowser(url) + return true + } + if (url.startsWith(Constants.Scheme.WALLET_CONNECT_PREFIX, true) || url.startsWith(Constants.Scheme.MIXIN_WC) || url.startsWith(Constants.Scheme.HTTPS_MIXIN_WC) @@ -1977,18 +2019,7 @@ class WebFragment : BaseFragment() { } private fun isFallbackUrlValid(fallbackUrl: String): Boolean { - try { - val anyCaseScheme = URI(fallbackUrl).scheme - val scheme = anyCaseScheme?.lowercase(Locale.US) - if ("http" == scheme || "https" == scheme) { - return true - } else { - Timber.w("Fallback URI uses unsupported scheme: $scheme. Try http or https.") - } - } catch (e: URISyntaxException) { - Timber.w("URISyntaxException parsing fallback URI") - } - return false + return canLoadUrl(fallbackUrl) } interface OnPageFinishedListener { diff --git a/app/src/main/java/one/mixin/android/vo/AppCardData.kt b/app/src/main/java/one/mixin/android/vo/AppCardData.kt index d74253fbef..5504e739ec 100644 --- a/app/src/main/java/one/mixin/android/vo/AppCardData.kt +++ b/app/src/main/java/one/mixin/android/vo/AppCardData.kt @@ -15,6 +15,9 @@ import one.mixin.android.extension.isMixinUrl import one.mixin.android.extension.toDrawable import one.mixin.android.util.GsonHelper import timber.log.Timber +import java.net.Inet4Address +import java.net.InetAddress +import java.net.URI import kotlin.math.max @Parcelize @@ -64,11 +67,11 @@ data class AppCardData( val hashCover: Boolean get() { if (oldVersion) return false - return !coverUrl.isNullOrBlank() || !cover?.url.isNullOrBlank() + return coverUrl.safeAppCardImageUrl() != null || cover?.url.safeAppCardImageUrl() != null } val hasMediaCover: Boolean - get() = !coverUrl.isNullOrBlank() + get() = coverUrl.safeAppCardImageUrl() != null val hasValidCoverSize: Boolean get() { @@ -196,7 +199,7 @@ data class Cover( fun MessageItem.appCardCoverUrl(): String? = if (isAppCard()) { - appCardData?.let { it.coverUrl?.takeIf(String::isNotBlank) ?: it.cover?.url?.takeIf(String::isNotBlank) } + appCardData?.let { it.coverUrl.safeAppCardImageUrl() ?: it.cover?.url.safeAppCardImageUrl() } } else { null } @@ -208,7 +211,49 @@ fun MessageItem.isAppCardWithMediaCover(): Boolean { fun MessageItem.appCardMediaCoverUrl(): String? = if (isAppCard()) { - appCardData?.coverUrl?.takeIf(String::isNotBlank) + appCardData?.coverUrl.safeAppCardImageUrl() } else { null } + +internal fun String?.safeAppCardImageUrl(): String? { + val value = this?.takeIf { it.isNotBlank() && it.length <= APP_CARD_IMAGE_URL_MAX_LENGTH } ?: return null + val uri = runCatching { URI(value) }.getOrNull() ?: return null + if (!uri.scheme.equals("https", ignoreCase = true) || uri.rawUserInfo != null) return null + val host = uri.host?.lowercase()?.takeIf(String::isNotBlank) ?: return null + if (host == "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + (!host.contains('.') && !host.contains(':')) || + host.isBlockedIpLiteral() + ) { + return null + } + return value +} + +private fun String.isBlockedIpLiteral(): Boolean { + val isLiteral = contains(':') || all { it.isDigit() || it == '.' } + if (!isLiteral) return false + val address = runCatching { InetAddress.getByName(this) }.getOrNull() ?: return true + if (address.isAnyLocalAddress || + address.isLoopbackAddress || + address.isLinkLocalAddress || + address.isSiteLocalAddress || + address.isMulticastAddress + ) { + return true + } + if (address is Inet4Address) { + val bytes = address.address.map(Byte::toInt).map { it and 0xff } + return bytes[0] == 0 || + bytes[0] == 100 && bytes[1] in 64..127 || + bytes[0] == 192 && bytes[1] == 0 || + bytes[0] == 198 && bytes[1] in 18..19 || + bytes[0] >= 224 + } + return false +} + +private const val APP_CARD_IMAGE_URL_MAX_LENGTH = 2048 diff --git a/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt b/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt index e602f77ea9..5d851643d3 100644 --- a/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt +++ b/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt @@ -102,4 +102,37 @@ class AppCardDataTest { assertTrue(coverUrlOnly.hasMediaCover) assertFalse(nestedCover.hasMediaCover) } + + @Test + fun `safe image url accepts public https url`() { + assertTrue("https://cdn.example.com/cover.jpg".safeAppCardImageUrl() != null) + } + + @Test + fun `safe image url rejects non-https and local targets`() { + assertFalse("http://cdn.example.com/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://localhost/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://127.0.0.1/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://10.0.0.1/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://[::1]/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://router.local/cover.jpg".safeAppCardImageUrl() != null) + } + + @Test + fun `unsafe media cover is not exposed`() { + val appCardData = AppCardData( + appId = "app-id", + iconUrl = null, + coverUrl = "https://127.0.0.1/cover.jpg", + cover = null, + title = "title", + description = null, + action = null, + updatedAt = null, + shareable = null, + ) + + assertFalse(appCardData.hasMediaCover) + assertFalse(appCardData.hashCover) + } } From 0c6212d2edd1d27622ef368e2c487852c70d3dd5 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 30 Jul 2026 15:09:04 +0800 Subject: [PATCH 2/4] fix(image): block private network targets Filter every DNS result and verify the connected route for image requests, including redirects. Disable proxies for the image client so target resolution cannot bypass the public-address policy. Cover IPv6 ULA and reserved ranges. --- .../one/mixin/android/MixinApplication.kt | 21 ++--- .../android/util/image/ImageNetworkPolicy.kt | 78 +++++++++++++++++++ .../java/one/mixin/android/vo/AppCardData.kt | 29 +------ .../util/image/ImageNetworkPolicyTest.kt | 60 ++++++++++++++ .../one/mixin/android/vo/AppCardDataTest.kt | 1 + 5 files changed, 155 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/util/image/ImageNetworkPolicy.kt create mode 100644 app/src/test/java/one/mixin/android/util/image/ImageNetworkPolicyTest.kt diff --git a/app/src/main/java/one/mixin/android/MixinApplication.kt b/app/src/main/java/one/mixin/android/MixinApplication.kt index cebdbf792f..65f90cd52f 100644 --- a/app/src/main/java/one/mixin/android/MixinApplication.kt +++ b/app/src/main/java/one/mixin/android/MixinApplication.kt @@ -88,6 +88,7 @@ import one.mixin.android.util.CursorWindowFixer import one.mixin.android.util.MemoryCallback import one.mixin.android.util.analytics.ThirdPartyUserIdentity import one.mixin.android.util.debug.FileLogTree +import one.mixin.android.util.image.enforcePublicImageTargets import one.mixin.android.util.initNativeLibs import one.mixin.android.util.mlkit.entityInitialize import one.mixin.android.util.reportException @@ -552,15 +553,17 @@ open class MixinApplication : return ImageLoader.Builder(this) .components { add(OkHttpNetworkFetcherFactory(callFactory = { - OkHttpClient.Builder().addInterceptor { chain -> - val original = chain.request() - val requestBuilder = - original.newBuilder() - .header("User-Agent", API_UA) - .method(original.method, original.body) - val request = requestBuilder.build() - chain.proceed(request) - }.build() + OkHttpClient.Builder() + .enforcePublicImageTargets() + .addInterceptor { chain -> + val original = chain.request() + val requestBuilder = + original.newBuilder() + .header("User-Agent", API_UA) + .method(original.method, original.body) + val request = requestBuilder.build() + chain.proceed(request) + }.build() }, cacheStrategy = { CacheControlCacheStrategy() })) if (SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory()) diff --git a/app/src/main/java/one/mixin/android/util/image/ImageNetworkPolicy.kt b/app/src/main/java/one/mixin/android/util/image/ImageNetworkPolicy.kt new file mode 100644 index 0000000000..5de41194fb --- /dev/null +++ b/app/src/main/java/one/mixin/android/util/image/ImageNetworkPolicy.kt @@ -0,0 +1,78 @@ +package one.mixin.android.util.image + +import okhttp3.Dns +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.Proxy +import java.net.UnknownHostException +import java.util.Locale + +internal fun OkHttpClient.Builder.enforcePublicImageTargets(): OkHttpClient.Builder = + dns(PublicImageDns()) + .proxy(Proxy.NO_PROXY) + .addNetworkInterceptor(PublicImageNetworkInterceptor) + +internal class PublicImageDns( + private val delegate: Dns = Dns.SYSTEM, +) : Dns { + override fun lookup(hostname: String): List { + if (hostname.isBlockedImageHostname()) { + throw blockedImageTarget() + } + return delegate.lookup(hostname) + .filterNot(InetAddress::isBlockedImageAddress) + .ifEmpty { throw blockedImageTarget() } + } +} + +private object PublicImageNetworkInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val address = chain.connection()?.route()?.socketAddress?.address + if (address == null || address.isBlockedImageAddress()) { + throw IOException("Blocked non-public image target") + } + return chain.proceed(chain.request()) + } +} + +internal fun String.isBlockedImageHostname(): Boolean { + val host = trimEnd('.').lowercase(Locale.US) + return host == "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + (!host.contains('.') && !host.contains(':')) +} + +internal fun InetAddress.isBlockedImageAddress(): Boolean { + if (isAnyLocalAddress || + isLoopbackAddress || + isLinkLocalAddress || + isSiteLocalAddress || + isMulticastAddress + ) { + return true + } + val bytes = address.map { it.toInt() and 0xff } + return when (this) { + is Inet4Address -> + bytes[0] == 0 || + bytes[0] == 100 && bytes[1] in 64..127 || + bytes[0] == 192 && bytes[1] == 0 || + bytes[0] == 198 && bytes[1] in 18..19 || + bytes[0] >= 224 + + is Inet6Address -> + bytes[0] and 0xfe == 0xfc || + bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0d && bytes[3] == 0xb8 + + else -> true + } +} + +private fun blockedImageTarget() = UnknownHostException("Blocked non-public image target") diff --git a/app/src/main/java/one/mixin/android/vo/AppCardData.kt b/app/src/main/java/one/mixin/android/vo/AppCardData.kt index 5504e739ec..f3a31c06a1 100644 --- a/app/src/main/java/one/mixin/android/vo/AppCardData.kt +++ b/app/src/main/java/one/mixin/android/vo/AppCardData.kt @@ -14,8 +14,9 @@ import one.mixin.android.crypto.Base64 import one.mixin.android.extension.isMixinUrl import one.mixin.android.extension.toDrawable import one.mixin.android.util.GsonHelper +import one.mixin.android.util.image.isBlockedImageAddress +import one.mixin.android.util.image.isBlockedImageHostname import timber.log.Timber -import java.net.Inet4Address import java.net.InetAddress import java.net.URI import kotlin.math.max @@ -221,13 +222,7 @@ internal fun String?.safeAppCardImageUrl(): String? { val uri = runCatching { URI(value) }.getOrNull() ?: return null if (!uri.scheme.equals("https", ignoreCase = true) || uri.rawUserInfo != null) return null val host = uri.host?.lowercase()?.takeIf(String::isNotBlank) ?: return null - if (host == "localhost" || - host.endsWith(".localhost") || - host.endsWith(".local") || - host.endsWith(".internal") || - (!host.contains('.') && !host.contains(':')) || - host.isBlockedIpLiteral() - ) { + if (host.isBlockedImageHostname() || host.isBlockedIpLiteral()) { return null } return value @@ -237,23 +232,7 @@ private fun String.isBlockedIpLiteral(): Boolean { val isLiteral = contains(':') || all { it.isDigit() || it == '.' } if (!isLiteral) return false val address = runCatching { InetAddress.getByName(this) }.getOrNull() ?: return true - if (address.isAnyLocalAddress || - address.isLoopbackAddress || - address.isLinkLocalAddress || - address.isSiteLocalAddress || - address.isMulticastAddress - ) { - return true - } - if (address is Inet4Address) { - val bytes = address.address.map(Byte::toInt).map { it and 0xff } - return bytes[0] == 0 || - bytes[0] == 100 && bytes[1] in 64..127 || - bytes[0] == 192 && bytes[1] == 0 || - bytes[0] == 198 && bytes[1] in 18..19 || - bytes[0] >= 224 - } - return false + return address.isBlockedImageAddress() } private const val APP_CARD_IMAGE_URL_MAX_LENGTH = 2048 diff --git a/app/src/test/java/one/mixin/android/util/image/ImageNetworkPolicyTest.kt b/app/src/test/java/one/mixin/android/util/image/ImageNetworkPolicyTest.kt new file mode 100644 index 0000000000..9ce43619ab --- /dev/null +++ b/app/src/test/java/one/mixin/android/util/image/ImageNetworkPolicyTest.kt @@ -0,0 +1,60 @@ +package one.mixin.android.util.image + +import okhttp3.Dns +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.InetAddress +import java.net.UnknownHostException + +class ImageNetworkPolicyTest { + @Test + fun `dns keeps only public addresses`() { + val privateAddress = InetAddress.getByName("10.0.0.1") + val publicAddress = InetAddress.getByName("8.8.8.8") + val dns = PublicImageDns(dnsOf(privateAddress, publicAddress)) + + assertEquals(listOf(publicAddress), dns.lookup("cdn.example.com")) + } + + @Test(expected = UnknownHostException::class) + fun `dns rejects private-only addresses`() { + val dns = PublicImageDns(dnsOf(InetAddress.getByName("fd00::1"))) + + dns.lookup("cdn.example.com") + } + + @Test + fun `private and reserved addresses are blocked`() { + listOf( + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.1.1", + "172.16.0.1", + "192.168.0.1", + "198.18.0.1", + "224.0.0.1", + "::1", + "fe80::1", + "fc00::1", + "fd00::1", + "2001:db8::1", + ).forEach { address -> + assertTrue(address, InetAddress.getByName(address).isBlockedImageAddress()) + } + } + + @Test + fun `public addresses are allowed`() { + assertFalse(InetAddress.getByName("8.8.8.8").isBlockedImageAddress()) + assertFalse(InetAddress.getByName("2606:4700:4700::1111").isBlockedImageAddress()) + } + + private fun dnsOf(vararg addresses: InetAddress) = + object : Dns { + override fun lookup(hostname: String): List = addresses.toList() + } +} diff --git a/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt b/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt index 5d851643d3..212099ca23 100644 --- a/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt +++ b/app/src/test/java/one/mixin/android/vo/AppCardDataTest.kt @@ -115,6 +115,7 @@ class AppCardDataTest { assertFalse("https://127.0.0.1/cover.jpg".safeAppCardImageUrl() != null) assertFalse("https://10.0.0.1/cover.jpg".safeAppCardImageUrl() != null) assertFalse("https://[::1]/cover.jpg".safeAppCardImageUrl() != null) + assertFalse("https://[fd00::1]/cover.jpg".safeAppCardImageUrl() != null) assertFalse("https://router.local/cover.jpg".safeAppCardImageUrl() != null) } From 2cd57ed6fb33f4d27e7814187026f59c4880d268 Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Thu, 30 Jul 2026 15:10:23 +0800 Subject: [PATCH 3/4] fix(web): keep subframe navigation contained Block untrusted subframe requests without launching the system browser. Track redirects and external navigation only for main-frame requests. --- app/src/main/java/one/mixin/android/ui/web/WebFragment.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt index 03f8f7fe18..3529389d88 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt @@ -1933,14 +1933,18 @@ class WebFragment : BaseFragment() { view: WebView?, request: WebResourceRequest?, ): Boolean { - redirect = true if (view == null || request == null) { return super.shouldOverrideUrlLoading(view, request) } + if (request.isForMainFrame) { + redirect = true + } val url = request.url.toString() if (url.isWebUrl() && !canLoadUrl(url)) { - context.openInBrowser(url) + if (request.isForMainFrame) { + context.openInBrowser(url) + } return true } From f24ad748f68ea315f4cf98a6bd07007b68c220de Mon Sep 17 00:00:00 2001 From: SeniorZhai Date: Fri, 14 Aug 2026 15:54:59 +0800 Subject: [PATCH 4/4] fix(web): gate native bridges by trusted context --- .../ui/search/SearchExploreFragment.kt | 11 ++++++-- .../android/ui/search/SearchSingleFragment.kt | 2 +- .../mixin/android/ui/web/FloatingManager.kt | 2 ++ .../one/mixin/android/ui/web/WebActivity.kt | 24 +++++++++++++++- .../mixin/android/ui/web/WebBridgePolicy.kt | 15 ++++++++++ .../one/mixin/android/ui/web/WebFragment.kt | 11 ++++++-- .../android/web3/dapp/SearchDappFragment.kt | 2 +- .../android/ui/web/WebBridgePolicyTest.kt | 28 +++++++++++++++++++ 8 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/one/mixin/android/ui/web/WebBridgePolicy.kt create mode 100644 app/src/test/java/one/mixin/android/ui/web/WebBridgePolicyTest.kt diff --git a/app/src/main/java/one/mixin/android/ui/search/SearchExploreFragment.kt b/app/src/main/java/one/mixin/android/ui/search/SearchExploreFragment.kt index 033dc6f08d..9caa9fe458 100644 --- a/app/src/main/java/one/mixin/android/ui/search/SearchExploreFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/search/SearchExploreFragment.kt @@ -196,7 +196,7 @@ class SearchExploreFragment : BaseFragment(R.layout.fragment_search_explore) { override fun onDappClick(dapp: Dapp) { searchViewModel.saveRecentSearch(requireContext().defaultSharedPreferences, RecentSearch(RecentSearchType.DAPP, iconUrl = dapp.iconUrl, title = dapp.name, subTitle = dapp.homeUrl)) - WebActivity.show(requireContext(), dapp.homeUrl, null) + WebActivity.show(requireContext(), dapp.homeUrl, null, allowWalletBridge = true) } override fun onTipClick() { @@ -241,7 +241,7 @@ class SearchExploreFragment : BaseFragment(R.layout.fragment_search_explore) { binding.recent.setContent { RecentSearchPage ({ dapp -> searchViewModel.saveRecentSearch(requireContext().defaultSharedPreferences, RecentSearch(RecentSearchType.DAPP, iconUrl = dapp.iconUrl, title = dapp.name, subTitle = dapp.homeUrl)) - WebActivity.show(requireContext(), dapp.homeUrl, null) + WebActivity.show(requireContext(), dapp.homeUrl, null, allowWalletBridge = true) }, {search-> when(search.type){ RecentSearchType.BOT-> { @@ -263,7 +263,12 @@ class SearchExploreFragment : BaseFragment(R.layout.fragment_search_explore) { } } RecentSearchType.DAPP->{ - WebActivity.show(requireContext(), search.subTitle?:"", null) + WebActivity.show( + requireContext(), + search.subTitle ?: "", + null, + allowWalletBridge = true, + ) } RecentSearchType.LINK->{ search.subTitle?.openAsUrlOrWeb(requireContext(), null, parentFragmentManager, lifecycleScope) diff --git a/app/src/main/java/one/mixin/android/ui/search/SearchSingleFragment.kt b/app/src/main/java/one/mixin/android/ui/search/SearchSingleFragment.kt index 99520610a3..79ec5285a8 100644 --- a/app/src/main/java/one/mixin/android/ui/search/SearchSingleFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/search/SearchSingleFragment.kt @@ -160,7 +160,7 @@ class SearchSingleFragment : BaseFragment(R.layout.fragment_search_single) { override fun onDappClick(dapp: Dapp) { searchViewModel.saveRecentSearch(requireContext().defaultSharedPreferences, RecentSearch(RecentSearchType.DAPP, iconUrl = dapp.iconUrl, title = dapp.name, subTitle = dapp.homeUrl)) RxBus.publish(SearchEvent()) - WebActivity.show(requireContext(), dapp.homeUrl, null) + WebActivity.show(requireContext(), dapp.homeUrl, null, allowWalletBridge = true) } override fun onBotClick(bot: SearchBot) { diff --git a/app/src/main/java/one/mixin/android/ui/web/FloatingManager.kt b/app/src/main/java/one/mixin/android/ui/web/FloatingManager.kt index 4ec06b8d09..eb6d84c06b 100644 --- a/app/src/main/java/one/mixin/android/ui/web/FloatingManager.kt +++ b/app/src/main/java/one/mixin/android/ui/web/FloatingManager.kt @@ -145,6 +145,8 @@ data class WebClip( val conversationId: String?, @SerializedName("shareable") val shareable: Boolean?, + @SerializedName("injectable") + val injectable: Boolean, @Transient val webView: MixinWebView?, @Transient val isFinished: Boolean = false, ) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt b/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt index 3a7caab719..b5c737174d 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebActivity.kt @@ -17,6 +17,7 @@ import one.mixin.android.extension.blurBitmap import one.mixin.android.extension.colorFromAttribute import one.mixin.android.extension.isDarkColor import one.mixin.android.extension.isNightMode +import one.mixin.android.extension.matchResourcePattern import one.mixin.android.extension.openCustomerServiceIfMatched import one.mixin.android.extension.supportsS import one.mixin.android.session.Session @@ -27,6 +28,13 @@ import one.mixin.android.vo.AppCardData import one.mixin.android.vo.generateConversationId import one.mixin.android.widget.SixLayout +private fun isTrustedAppWebUrl(url: String, app: App?): Boolean = + app != null && + ( + url.matchResourcePattern(app.resourcePatterns) || + isTrustedWebUrl(url, secureWebOrigin(app.homeUri)) + ) + @AndroidEntryPoint class WebActivity : BaseActivity() { companion object { @@ -48,7 +56,8 @@ class WebActivity : BaseActivity() { app: App? = null, appCard: AppCardData? = null, saveName: Boolean? = null, - fixedTitle: String? = null + fixedTitle: String? = null, + allowWalletBridge: Boolean = false, ) { if (context.openCustomerServiceIfMatched(url)) { return @@ -72,6 +81,13 @@ class WebActivity : BaseActivity() { putParcelable(WebFragment.ARGS_APP_CARD, appCard) putBoolean(WebFragment.ARGS_SAVE_NAME, saveName ?: false) putString(WebFragment.ARGS_FIXED_TITLE, fixedTitle) + val bridgePolicy = webBridgePolicy( + trustedAppUrl = appCard?.appId != null || + isTrustedAppWebUrl(url, app), + dappBrowser = allowWalletBridge, + ) + putBoolean(WebFragment.ARGS_MIXIN_CONTEXT, bridgePolicy.mixinContext) + putBoolean(WebFragment.ARGS_INJECTABLE, bridgePolicy.wallet) }, ) }, @@ -183,6 +199,12 @@ class WebActivity : BaseActivity() { extras.putInt(WebFragment.ARGS_INDEX, index) extras.putParcelable(WebFragment.ARGS_APP, clip.app) clip.shareable?.let { extras.putBoolean(WebFragment.ARGS_SHAREABLE, it) } + val bridgePolicy = webBridgePolicy( + trustedAppUrl = isTrustedAppWebUrl(clip.url, clip.app), + dappBrowser = clip.injectable, + ) + extras.putBoolean(WebFragment.ARGS_MIXIN_CONTEXT, bridgePolicy.mixinContext) + extras.putBoolean(WebFragment.ARGS_INJECTABLE, bridgePolicy.wallet) isExpand = true val safeColor: Int = clip.titleColor.apply { val isDark: Boolean = isDarkColor(this) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebBridgePolicy.kt b/app/src/main/java/one/mixin/android/ui/web/WebBridgePolicy.kt new file mode 100644 index 0000000000..f0efa27611 --- /dev/null +++ b/app/src/main/java/one/mixin/android/ui/web/WebBridgePolicy.kt @@ -0,0 +1,15 @@ +package one.mixin.android.ui.web + +internal data class WebBridgePolicy( + val mixinContext: Boolean, + val wallet: Boolean, +) + +internal fun webBridgePolicy( + trustedAppUrl: Boolean, + dappBrowser: Boolean, +): WebBridgePolicy = + WebBridgePolicy( + mixinContext = trustedAppUrl, + wallet = trustedAppUrl || dappBrowser, + ) diff --git a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt index 3529389d88..5bb2fe762b 100644 --- a/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt +++ b/app/src/main/java/one/mixin/android/ui/web/WebFragment.kt @@ -235,6 +235,7 @@ class WebFragment : BaseFragment() { const val ARGS_SHAREABLE = "args_shareable" const val ARGS_SAVE_NAME = "args_save_name" const val ARGS_INJECTABLE = "args_injectable" + const val ARGS_MIXIN_CONTEXT = "args_mixin_context" const val ARGS_FIXED_TITLE = "args_fixed_title" const val themeColorScript = """ @@ -272,7 +273,10 @@ class WebFragment : BaseFragment() { } private val injectable: Boolean by lazy { - requireArguments().getBoolean(ARGS_INJECTABLE, true) + requireArguments().getBoolean(ARGS_INJECTABLE, false) + } + private val mixinContextInjectable: Boolean by lazy { + requireArguments().getBoolean(ARGS_MIXIN_CONTEXT, false) } private val trustedWebOrigin: String? by lazy { secureWebOrigin(url) @@ -994,7 +998,9 @@ class WebFragment : BaseFragment() { }, ) if (injectable) { - webAppInterface?.let { webView.addJavascriptInterface(it, "MixinContext") } + if (mixinContextInjectable) { + webAppInterface?.let { webView.addJavascriptInterface(it, "MixinContext") } + } webView.addJavascriptInterface( Web3Interface( onWalletActionSuccessful = { e -> @@ -1350,6 +1356,7 @@ class WebFragment : BaseFragment() { icon, conversationId, appCard?.shareable ?: shareable, + injectable, webView, isFinished, ) diff --git a/app/src/main/java/one/mixin/android/web3/dapp/SearchDappFragment.kt b/app/src/main/java/one/mixin/android/web3/dapp/SearchDappFragment.kt index 78fc303224..4f4835020e 100644 --- a/app/src/main/java/one/mixin/android/web3/dapp/SearchDappFragment.kt +++ b/app/src/main/java/one/mixin/android/web3/dapp/SearchDappFragment.kt @@ -31,7 +31,7 @@ class SearchDappFragment : BaseFragment(R.layout.fragment_search_dapps) { private val searchAdapter: SearchDappAdapter by lazy { SearchDappAdapter { url -> - WebActivity.show(requireContext(), url, null) + WebActivity.show(requireContext(), url, null, allowWalletBridge = true) } } diff --git a/app/src/test/java/one/mixin/android/ui/web/WebBridgePolicyTest.kt b/app/src/test/java/one/mixin/android/ui/web/WebBridgePolicyTest.kt new file mode 100644 index 0000000000..7c8c36a7ab --- /dev/null +++ b/app/src/test/java/one/mixin/android/ui/web/WebBridgePolicyTest.kt @@ -0,0 +1,28 @@ +package one.mixin.android.ui.web + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WebBridgePolicyTest { + @Test + fun genericWebContentDoesNotReceiveNativeBridge() { + val policy = webBridgePolicy(trustedAppUrl = false, dappBrowser = false) + assertFalse(policy.mixinContext) + assertFalse(policy.wallet) + } + + @Test + fun trustedAppReceivesBothBridges() { + val policy = webBridgePolicy(trustedAppUrl = true, dappBrowser = false) + assertTrue(policy.mixinContext) + assertTrue(policy.wallet) + } + + @Test + fun dappBrowserReceivesOnlyWalletBridge() { + val policy = webBridgePolicy(trustedAppUrl = false, dappBrowser = true) + assertFalse(policy.mixinContext) + assertTrue(policy.wallet) + } +}