Updated on 2026-08-14
This commit is contained in:
parent
78cb0fc750
commit
39d4997aa7
4 changed files with 96 additions and 11 deletions
|
|
@ -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,18 +417,18 @@ 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)
|
||||
|
|
@ -434,6 +436,14 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
|
|||
}
|
||||
}
|
||||
|
||||
private fun launchDeepLink(deeplinkUri: Uri, isFromOnNewIntent: Boolean) {
|
||||
deeplinkFactory.handleDeeplink(
|
||||
deeplinkUri = deeplinkUri,
|
||||
coroutineScope = lifecycleScope,
|
||||
isFromOnNewIntent = isFromOnNewIntent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendStakingUnsubmittedHashes() {
|
||||
lifecycleScope.launch {
|
||||
sendUnsubmittedHashesUseCase.invoke()
|
||||
|
|
|
|||
|
|
@ -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<String, String>) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue