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 95ec8ccd6a..44844054c6 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 @@ -42,8 +42,6 @@ import androidx.media3.common.util.UnstableApi import androidx.paging.PagedList import androidx.viewpager2.widget.ViewPager2 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 @@ -86,7 +84,6 @@ import one.mixin.android.util.AnimationProperties import one.mixin.android.util.SensorOrientationChangeNotifier import one.mixin.android.util.SystemUIManager import one.mixin.android.util.VideoPlayer -import one.mixin.android.util.image.withDiskCacheFile import one.mixin.android.util.reportEvent import one.mixin.android.util.rxpermission.RxPermissions import one.mixin.android.vo.FixedMessageDataSource @@ -471,20 +468,14 @@ 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.withDiskCacheFile(result) { cachedFile -> - val destination = createAppCardCoverCacheFile() - try { - cachedFile.copy(destination) - destination - } catch (e: Exception) { - destination.delete() - throw e - } + imageLoader.diskCache?.openSnapshot(coverUrl)?.use { snapshot -> + val destination = createAppCardCoverCacheFile() + try { + snapshot.data.toFile().copy(destination) + destination + } catch (e: Exception) { + destination.delete() + throw e } } } catch (e: Exception) { 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 1eb4901c81..662790c472 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 21d2681f7b..6f30c5deb7 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 @@ -198,10 +198,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 { @@ -218,6 +237,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 = """ @@ -255,7 +275,13 @@ 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) } private val fixedTitle: String? by lazy { requireArguments().getString(ARGS_FIXED_TITLE) @@ -558,7 +584,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" @@ -579,6 +605,7 @@ class WebFragment : BaseFragment() { this.parentFragmentManager, requireActivity().activityResultRegistry, injectable, + trustedWebOrigin, lifecycleScope, { url -> currentUrl = url @@ -926,6 +953,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 { @@ -979,66 +1011,70 @@ class WebFragment : BaseFragment() { verifyPin(callback) }, ) - 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) { + if (mixinContextInjectable) { + 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 @@ -1173,11 +1209,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 @@ -1359,6 +1391,7 @@ class WebFragment : BaseFragment() { icon, conversationId, appCard?.shareable ?: shareable, + injectable, webView, isFinished, ) @@ -1850,6 +1883,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, @@ -1861,6 +1895,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() } @@ -1872,6 +1911,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) { @@ -1936,12 +1980,21 @@ 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)) { + if (request.isForMainFrame) { + 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) @@ -2017,18 +2070,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/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/util/image/MixinImageLoader.kt b/app/src/main/java/one/mixin/android/util/image/MixinImageLoader.kt index d1b35f5810..973c5cbbf2 100644 --- a/app/src/main/java/one/mixin/android/util/image/MixinImageLoader.kt +++ b/app/src/main/java/one/mixin/android/util/image/MixinImageLoader.kt @@ -80,6 +80,7 @@ private class PersistentImageCacheStrategy : CacheStrategy { private fun newImageCallFactory(): Call.Factory = OkHttpClient.Builder() + .enforcePublicImageTargets() .addInterceptor { chain -> val original = chain.request() val request = 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..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,7 +14,11 @@ 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.InetAddress +import java.net.URI import kotlin.math.max @Parcelize @@ -64,11 +68,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 +200,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 +212,27 @@ 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.isBlockedImageHostname() || 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 + return address.isBlockedImageAddress() +} + +private const val APP_CARD_IMAGE_URL_MAX_LENGTH = 2048 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) + } +} 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 e602f77ea9..212099ca23 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,38 @@ 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://[fd00::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) + } }