Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<UserRecoverableAuthExceptionFragment>(
fragmentArgs = args
)
}

private fun assertIntentsNotContaining(intent: Intent) {
Intents.intended(not(hasComponent(intent.component)))
}

private companion object {
const val KEY_RECOVERABLE_INTENT = "recoverableIntent"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,25 @@ abstract class BaseActivity<T : ViewBinding> : AppCompatActivity() {
LogsUtil.d(tag, "onNewIntent = $intent")
}

protected fun sanitizeIntentForNavigation(
intent: Intent,
strippedDeepLinkDestinationIds: Set<Int>,
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")
Expand Down Expand Up @@ -215,4 +234,16 @@ abstract class BaseActivity<T : ViewBinding> : 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",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
}

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)
Expand All @@ -86,28 +90,18 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
}

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) {
recreate()
}
}

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
Expand Down Expand Up @@ -149,15 +143,6 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -> {
Expand Down Expand Up @@ -234,4 +254,8 @@ class UserRecoverableAuthExceptionFragment :
}
}
}

companion object {
private const val GOOGLE_PLAY_SERVICES_PACKAGE = "com.google.android.gms"
}
}
Loading