From 39d4997aa76d0770213142d7df5388f68ad04204 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 15 Jul 2026 15:56:26 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../main/java/com/tangem/tap/MainActivity.kt | 30 ++++++++---- .../tap/common/pushes/PushMessageHandler.kt | 5 +- .../routing/deeplink/PushDeeplinkPolicy.kt | 23 +++++++++ .../deeplink/PushDeeplinkPolicyTest.kt | 49 +++++++++++++++++++ 4 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicy.kt create mode 100644 common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicyTest.kt diff --git a/app/src/main/java/com/tangem/tap/MainActivity.kt b/app/src/main/java/com/tangem/tap/MainActivity.kt index c85354972d..60980a4a9e 100644 --- a/app/src/main/java/com/tangem/tap/MainActivity.kt +++ b/app/src/main/java/com/tangem/tap/MainActivity.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ActivityInfo import android.content.res.Configuration +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.KeyEvent @@ -32,6 +33,7 @@ import com.appsflyer.AppsFlyerLib import com.tangem.common.routing.AppRouter import com.tangem.common.routing.deeplink.DeeplinkConst.WEBLINK_KEY import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter +import com.tangem.common.routing.deeplink.PushDeeplinkPolicy import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.di.RootAppComponentContext @@ -415,25 +417,33 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder { } private fun handleDeepLink(intent: Intent, isFromOnNewIntent: Boolean) { - val deepLinkExtras = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri() + val externalDeepLink = intent.data + val extrasDeepLink = PayloadToDeeplinkConverter.convertBundle(intent.extras)?.toUri() val webLink = intent.getStringExtra(WEBLINK_KEY) - val receivedDeepLink = intent.data ?: deepLinkExtras - when { - receivedDeepLink != null -> { - deeplinkFactory.handleDeeplink( - deeplinkUri = receivedDeepLink, - coroutineScope = lifecycleScope, - isFromOnNewIntent = isFromOnNewIntent, - ) - } + // External deep links (browser / universal links) arrive via intent.data — never gated. + externalDeepLink != null -> launchDeepLink(externalDeepLink, isFromOnNewIntent) + // A deep link reconstructed from a notification payload must pass the push policy. If it is + // present but blocked, stop — do not fall back to another payload-supplied field (e.g. webLink). + extrasDeepLink != null -> + if (PushDeeplinkPolicy.isOpenableFromPush(scheme = extrasDeepLink.scheme, host = extrasDeepLink.host)) { + launchDeepLink(extrasDeepLink, isFromOnNewIntent) + } webLink?.uriValidate() == true -> { urlOpener.openUrl(webLink) } } } + private fun launchDeepLink(deeplinkUri: Uri, isFromOnNewIntent: Boolean) { + deeplinkFactory.handleDeeplink( + deeplinkUri = deeplinkUri, + coroutineScope = lifecycleScope, + isFromOnNewIntent = isFromOnNewIntent, + ) + } + private fun sendStakingUnsubmittedHashes() { lifecycleScope.launch { sendUnsubmittedHashesUseCase.invoke() diff --git a/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt b/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt index d8589b644f..1cd06a698f 100644 --- a/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/pushes/PushMessageHandler.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.core.net.toUri import com.tangem.common.routing.DeepLinkRoute import com.tangem.common.routing.deeplink.PayloadToDeeplinkConverter +import com.tangem.common.routing.deeplink.PushDeeplinkPolicy import com.tangem.utils.extensions.uriValidate import javax.inject.Inject @@ -21,7 +22,9 @@ internal class PushMessageHandler @Inject constructor( ) { fun onMessageReceived(data: Map) { - val deeplink = PayloadToDeeplinkConverter.convert(data)?.toUri() ?: return + val deeplink = PayloadToDeeplinkConverter.convert(data)?.toUri() + ?.takeIf { PushDeeplinkPolicy.isOpenableFromPush(scheme = it.scheme, host = it.host) } + ?: return val queryParams = deeplink.getQueryParams() when (deeplink.host) { DeepLinkRoute.TokenDetails.host -> tokenDetailsPushHandler.handle(queryParams) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicy.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicy.kt new file mode 100644 index 0000000000..8c6d07a297 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicy.kt @@ -0,0 +1,23 @@ +package com.tangem.common.routing.deeplink + +import com.tangem.common.routing.DeepLinkRoute +import com.tangem.common.routing.DeepLinkScheme + +/** + * Security policy for deep links that originate from a push-notification payload. + * + * A push payload can carry a ready-made deep link that is routed verbatim when the notification is tapped. Applied at + * the push boundary (not inside the converter), it blocks the critical routes a notification must never reach: + * WalletConnect pairing (both the raw `wc:` scheme and `tangem://wc`, which pairs with an attacker-controlled session) + * and the sell redirect (which prefills the Send screen with a payload-supplied address). Every other route is allowed. + */ +object PushDeeplinkPolicy { + + fun isOpenableFromPush(scheme: String?, host: String?): Boolean = when { + // A raw "wc:" link is paired straight from the scheme, without a host. + scheme?.lowercase() == DeepLinkScheme.WalletConnect.scheme -> false + host == DeepLinkRoute.WalletConnect.host -> false + host == DeepLinkRoute.SellRedirect.host -> false + else -> true + } +} \ No newline at end of file diff --git a/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicyTest.kt b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicyTest.kt new file mode 100644 index 0000000000..40a5f207a3 --- /dev/null +++ b/common/routing/src/test/kotlin/com/tangem/common/routing/deeplink/PushDeeplinkPolicyTest.kt @@ -0,0 +1,49 @@ +package com.tangem.common.routing.deeplink + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class PushDeeplinkPolicyTest { + + @ParameterizedTest + @MethodSource("provideTestModels") + fun isOpenableFromPush(model: Model) { + // Act + val actual = PushDeeplinkPolicy.isOpenableFromPush(scheme = model.scheme, host = model.host) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + data class Model(val scheme: String?, val host: String?, val expected: Boolean) + + private fun provideTestModels() = listOf( + // Blocked — the sell redirect (Send prefill) and WalletConnect pairing + Model(scheme = "tangem", host = "redirect_sell", expected = false), + Model(scheme = "tangem", host = "wc", expected = false), + Model(scheme = "wc", host = null, expected = false), + Model(scheme = "wc", host = "anything", expected = false), + Model(scheme = "WC", host = null, expected = false), + // Allowed — everything else, including routes once flagged sensitive + Model(scheme = "tangem", host = "main", expected = true), + Model(scheme = "tangem", host = "token", expected = true), + Model(scheme = "tangem", host = "staking", expected = true), + Model(scheme = "tangem", host = "promo", expected = true), + Model(scheme = "tangem", host = "sell", expected = true), + Model(scheme = "tangem", host = "buy", expected = true), + Model(scheme = "tangem", host = "swap", expected = true), + Model(scheme = "tangem", host = "onramp", expected = true), + Model(scheme = "tangem", host = "onboard-visa", expected = true), + Model(scheme = "tangem", host = "survey", expected = true), + Model(scheme = "tangem", host = "pay-app-main", expected = true), + Model(scheme = "https", host = "tangem.com", expected = true), + // Allowed — unknown / missing hosts are not routable to any handler, so they are harmless + Model(scheme = "https", host = "evil.com", expected = true), + Model(scheme = "tangem", host = "unknown_route", expected = true), + Model(scheme = "tangem", host = null, expected = true), + Model(scheme = null, host = null, expected = true), + ) +} \ No newline at end of file