From b3302cd06e9998af11aba4238ae6ca37bc483af6 Mon Sep 17 00:00:00 2001 From: martgil Date: Tue, 8 Sep 2026 17:30:07 +0800 Subject: [PATCH 1/2] fix: Prevent deep link injection into recoverable auth intent --- .../email/ui/activity/BaseActivity.kt | 31 ++++++++++++++++ .../ui/activity/CreateMessageActivity.kt | 35 ++++++------------- .../UserRecoverableAuthExceptionFragment.kt | 28 +++++++++++++-- 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/BaseActivity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/BaseActivity.kt index 5b64681479..eeca46a9cb 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/BaseActivity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/BaseActivity.kt @@ -103,6 +103,25 @@ abstract class BaseActivity : AppCompatActivity() { LogsUtil.d(tag, "onNewIntent = $intent") } + protected fun sanitizeIntentForNavigation( + intent: Intent, + strippedDeepLinkDestinationIds: Set, + removeAllNavigationDeepLinkExtras: Boolean = false + ) { + val originalExtras = intent.extras ?: return + val shouldStripAll = removeAllNavigationDeepLinkExtras + val deepLinkIds = originalExtras.getIntArray(EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS) + val containsBlockedInternalDestination = + deepLinkIds?.any { it in strippedDeepLinkDestinationIds } == true + if (!shouldStripAll && !containsBlockedInternalDestination) { + return + } + val sanitizedExtras = Bundle(originalExtras).apply { + NAVIGATION_DEEP_LINK_EXTRA_KEYS.forEach(::remove) + } + intent.replaceExtras(sanitizedExtras) + } + override fun onStart() { super.onStart() LogsUtil.d(tag, "onStart") @@ -215,4 +234,16 @@ abstract class BaseActivity : AppCompatActivity() { } } } + + companion object { + private const val EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS = + "android-support-nav:controller:deepLinkIds" + private val NAVIGATION_DEEP_LINK_EXTRA_KEYS = setOf( + EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS, + "android-support-nav:controller:deepLinkArgs", + "android-support-nav:controller:deepLinkExtras", + "android-support-nav:controller:deepLinkHandled", + "android-support-nav:controller:deepLinkIntent", + ) + } } diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/CreateMessageActivity.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/CreateMessageActivity.kt index f372f9a636..e5c3c36faa 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/CreateMessageActivity.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/CreateMessageActivity.kt @@ -74,7 +74,11 @@ class CreateMessageActivity : BaseActivity(), } override fun onCreate(savedInstanceState: Bundle?) { - sanitizeIntentForNavigation(intent) + sanitizeIntentForNavigation( + intent = intent, + strippedDeepLinkDestinationIds = BLOCKED_DEEP_LINK_DESTINATION_IDS, + removeAllNavigationDeepLinkExtras = intent.action in PUBLIC_INTENT_ACTIONS + ) enableEdgeToEdge() super.onCreate(savedInstanceState) (navController as? NavHostController)?.enableOnBackPressed(true) @@ -86,7 +90,11 @@ class CreateMessageActivity : BaseActivity(), } override fun onNewIntent(intent: Intent) { - sanitizeIntentForNavigation(intent) + sanitizeIntentForNavigation( + intent = intent, + strippedDeepLinkDestinationIds = BLOCKED_DEEP_LINK_DESTINATION_IDS, + removeAllNavigationDeepLinkExtras = intent.action in PUBLIC_INTENT_ACTIONS + ) setIntent(intent) super.onNewIntent(intent) if (intent.action in PUBLIC_INTENT_ACTIONS) { @@ -94,20 +102,6 @@ class CreateMessageActivity : BaseActivity(), } } - private fun sanitizeIntentForNavigation(intent: Intent) { - val originalExtras = intent.extras ?: return - val shouldRemoveAllNavigationDeepLinkExtras = intent.action in PUBLIC_INTENT_ACTIONS - val deepLinkIds = originalExtras.getIntArray(EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS) - val containsBlockedInternalDestination = deepLinkIds?.any { it in BLOCKED_DEEP_LINK_DESTINATION_IDS } == true - if (!shouldRemoveAllNavigationDeepLinkExtras && !containsBlockedInternalDestination) { - return - } - val sanitizedExtras = Bundle(originalExtras).apply { - NAVIGATION_DEEP_LINK_EXTRA_KEYS.forEach(::remove) - } - intent.replaceExtras(sanitizedExtras) - } - private fun createStartDestinationArgs(intent: Intent): Bundle? { return if (intent.action in PUBLIC_INTENT_ACTIONS) { Bundle.EMPTY @@ -149,15 +143,6 @@ class CreateMessageActivity : BaseActivity(), private const val EXTRA_KEY_MESSAGE_TYPE = "messageType" private const val EXTRA_KEY_ENCRYPTED_BY_DEFAULT = "encryptedByDefault" private const val EXTRA_KEY_SERVICE_INFO = "serviceInfo" - private const val EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS = - "android-support-nav:controller:deepLinkIds" - private val NAVIGATION_DEEP_LINK_EXTRA_KEYS = setOf( - EXTRA_KEY_NAVIGATION_DEEP_LINK_IDS, - "android-support-nav:controller:deepLinkArgs", - "android-support-nav:controller:deepLinkExtras", - "android-support-nav:controller:deepLinkHandled", - "android-support-nav:controller:deepLinkIntent", - ) private val BLOCKED_DEEP_LINK_DESTINATION_IDS = setOf( R.id.createOutgoingMessageDialogFragment ) diff --git a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/UserRecoverableAuthExceptionFragment.kt b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/UserRecoverableAuthExceptionFragment.kt index dd36955050..bc5855c004 100644 --- a/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/UserRecoverableAuthExceptionFragment.kt +++ b/FlowCrypt/src/main/java/com/flowcrypt/email/ui/activity/fragment/UserRecoverableAuthExceptionFragment.kt @@ -9,6 +9,7 @@ import android.accounts.Account import android.accounts.AccountManager import android.annotation.SuppressLint import android.app.Activity +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -114,13 +115,32 @@ class UserRecoverableAuthExceptionFragment : ) } + private fun isValidGoogleRecoverableAuthIntent(intent: Intent?): Boolean { + if (intent == null) return false + val grantFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + if (intent.flags and grantFlags != 0) { + return false + } + val componentPackage = intent.component?.packageName + val targetPackage = componentPackage + ?: intent.resolveActivity(requireContext().packageManager)?.packageName + return targetPackage == GOOGLE_PLAY_SERVICES_PACKAGE + } + private fun initViews() { binding?.buttonReconnect?.setOnClickListener { account?.let { accountEntity -> when (accountEntity.accountType) { AccountEntity.ACCOUNT_TYPE_GOOGLE -> { - val recoverableIntent = args.recoverableIntent ?: return@setOnClickListener - forActivityResultSignInError.launch(recoverableIntent) + val recoverableIntent = args.recoverableIntent + if (isValidGoogleRecoverableAuthIntent(recoverableIntent)) { + forActivityResultSignInError.launch(recoverableIntent) + } else { + toast(R.string.access_was_not_granted) + } } AccountEntity.ACCOUNT_TYPE_OUTLOOK -> { @@ -234,4 +254,8 @@ class UserRecoverableAuthExceptionFragment : } } } + + companion object { + private const val GOOGLE_PLAY_SERVICES_PACKAGE = "com.google.android.gms" + } } From a92aee23379093309e5ca53923f9ff728ce7d06e Mon Sep 17 00:00:00 2001 From: martgil Date: Tue, 8 Sep 2026 17:55:49 +0800 Subject: [PATCH 2/2] test: add test --- ...hExceptionFragmentDeepLinkInjectionTest.kt | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest.kt diff --git a/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest.kt b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest.kt new file mode 100644 index 0000000000..22bbddaa78 --- /dev/null +++ b/FlowCrypt/src/androidTest/java/com/flowcrypt/email/ui/fragment/isolation/incontainer/UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest.kt @@ -0,0 +1,130 @@ +/* + * © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com + * Contributors: denbond7 + */ + +package com.flowcrypt.email.ui.fragment.isolation.incontainer + +import android.app.Activity +import android.app.Instrumentation +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.flowcrypt.email.R +import com.flowcrypt.email.base.BaseTest +import com.flowcrypt.email.database.entity.AccountEntity +import com.flowcrypt.email.junit.annotations.FlowCryptTestSettings +import com.flowcrypt.email.rules.AddAccountToDatabaseRule +import com.flowcrypt.email.rules.AddPrivateKeyToDatabaseRule +import com.flowcrypt.email.rules.ClearAppSettingsRule +import com.flowcrypt.email.rules.GrantPermissionRuleChooser +import com.flowcrypt.email.rules.RetryRule +import com.flowcrypt.email.rules.ScreenshotTestRule +import com.flowcrypt.email.ui.activity.fragment.UserRecoverableAuthExceptionFragment +import com.flowcrypt.email.util.AccountDaoManager +import org.hamcrest.Matchers.not +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +/** + * @author Denys Bondarenko + */ +@MediumTest +@RunWith(AndroidJUnit4::class) +@FlowCryptTestSettings(useIntents = true) +class UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest : BaseTest() { + + private val addAccountToDatabaseRule = + AddAccountToDatabaseRule( + account = AccountDaoManager.getDefaultAccountDao().copy( + accountType = AccountEntity.ACCOUNT_TYPE_GOOGLE + ) + ) + + @get:Rule + var ruleChain: TestRule = RuleChain + .outerRule(RetryRule.DEFAULT) + .around(ClearAppSettingsRule()) + .around(GrantPermissionRuleChooser.grant(android.Manifest.permission.POST_NOTIFICATIONS)) + .around(addAccountToDatabaseRule) + .around(AddPrivateKeyToDatabaseRule(addAccountToDatabaseRule.account)) + .around(ScreenshotTestRule()) + + @Test + fun testAttackerControlledForeignComponentIsNotLaunched() { + val attackerIntent = Intent().setComponent( + ComponentName("com.attacker.poc", "com.attacker.poc.StealActivity") + ) + + launchFragmentWithRecoverableIntent(attackerIntent) + clickReconnect() + + //an attacker controlled intent must never be launched + assertIntentsNotContaining(attackerIntent) + } + + @Test + fun testIntentWithUriPermissionGrantFlagIsNotLaunched() { + val attackerIntent = Intent() + .setComponent(ComponentName("com.attacker.poc", "com.attacker.poc.StealActivity")) + .setData(Uri.parse("content://com.flowcrypt.email.embedded.attachments/some-uuid")) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + launchFragmentWithRecoverableIntent(attackerIntent) + clickReconnect() + + assertIntentsNotContaining(attackerIntent) + } + + @Test + fun testLegitimateGoogleIntentIsStillLaunched() { + val googleIntent = Intent().setComponent( + ComponentName("com.google.android.gms", "com.google.android.gms.auth.uic.MintTokenActivity") + ) + + //no real Google Play services on the test device, so stub the launch to verify it is sent + Intents.intending(hasComponent(googleIntent.component)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, Intent())) + + launchFragmentWithRecoverableIntent(googleIntent) + clickReconnect() + + Intents.intended(hasComponent(googleIntent.component)) + } + + private fun clickReconnect() { + onView(withId(R.id.buttonReconnect)) + .check(matches(isDisplayed())) + .perform(click()) + } + + private fun launchFragmentWithRecoverableIntent(intent: Intent) { + val args = Bundle().apply { + putParcelable(KEY_RECOVERABLE_INTENT, intent) + } + launchFragmentInContainer( + fragmentArgs = args + ) + } + + private fun assertIntentsNotContaining(intent: Intent) { + Intents.intended(not(hasComponent(intent.component))) + } + + private companion object { + const val KEY_RECOVERABLE_INTENT = "recoverableIntent" + } +}