Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.RecordedRequest
import org.hamcrest.Matchers.allOf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.After
import org.junit.Before
import org.junit.Rule
Expand Down Expand Up @@ -91,15 +93,17 @@ class ComposeScreenExternalIntentsFlowTest : BaseTest() {
return MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND)
}
})
private val addAccountToDatabaseRule = AddAccountToDatabaseRule()
private val addPrivateKeyToDatabaseRule = AddPrivateKeyToDatabaseRule()

@get:Rule
var ruleChain: TestRule = RuleChain
.outerRule(RetryRule.DEFAULT)
.around(ClearAppSettingsRule())
.around(GrantPermissionRuleChooser.grant(android.Manifest.permission.POST_NOTIFICATIONS))
.around(mockWebServerRule)
.around(AddAccountToDatabaseRule())
.around(AddPrivateKeyToDatabaseRule())
.around(addAccountToDatabaseRule)
.around(addPrivateKeyToDatabaseRule)
.around(activeActivityRule)
.around(ScreenshotTestRule())

Expand Down Expand Up @@ -370,6 +374,37 @@ class ComposeScreenExternalIntentsFlowTest : BaseTest() {
checkViewsOnScreen(subject = Intent.EXTRA_SUBJECT, body = Intent.EXTRA_TEXT)
}

@Test
fun testIgnoreInternalNavigationDeepLinkExtrasForExternalSendIntent() {
val externalSubject = "safe external subject"
val externalBody = "safe external body"
val externalAttachmentName = atts.first().name
assertEquals(0, roomDatabase.msgDao().getOutboxMsgs(addAccountToDatabaseRule.account.email).size)
val intent = requireNotNull(
TestGeneralUtil.genIntentForNavigationComponent(
navGraphId = R.navigation.create_msg_graph,
activityClass = CreateMessageActivity::class.java,
destinationId = R.id.createOutgoingMessageDialogFragment,
)
).apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_SUBJECT, externalSubject)
putExtra(Intent.EXTRA_TEXT, externalBody)
putExtra(Intent.EXTRA_STREAM, genUriFromFile(atts.first()))
}

activeActivityRule.launch(intent)

checkViewsOnScreen(subject = externalSubject, body = externalBody, attachmentsCount = 1)
onView(withText(externalAttachmentName)).check(matches(isDisplayed()))
activeActivityRule.getNonNullScenario().onActivity { activity ->
assertFalse(activity.isFinishing)
assertFalse(activity.isDestroyed)
}
assertEquals(0, roomDatabase.msgDao().getOutboxMsgs(addAccountToDatabaseRule.account.email).size)
}

private fun genIntentForUri(action: String?, stringUri: String?): Intent {
return Intent(getTargetContext(), CreateMessageActivity::class.java).apply {
this.action = action
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
* Contributors: DenBond7
* Contributors: denbond7
*/

package com.flowcrypt.email.api.email.javamail
Expand All @@ -10,6 +10,7 @@ import android.net.Uri
import android.text.TextUtils
import com.flowcrypt.email.Constants
import com.flowcrypt.email.api.email.model.AttachmentInfo
import com.flowcrypt.email.util.OutgoingAttachmentUriValidator
import jakarta.activation.DataSource
import java.io.BufferedInputStream
import java.io.InputStream
Expand All @@ -25,6 +26,7 @@ open class AttachmentInfoDataSource(private val context: Context, val att: Attac

override fun getInputStream(): InputStream? {
return att.uri?.let { uri ->
OutgoingAttachmentUriValidator.requireAllowedUri(context, uri)
context.contentResolver.openInputStream(uri)?.let { stream -> BufferedInputStream(stream) }
} ?: att.rawData?.inputStream()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.flowcrypt.email.model.MessageEncryptionType
import com.flowcrypt.email.security.SecurityUtils
import com.flowcrypt.email.security.pgp.PgpEncryptAndOrSign
import com.flowcrypt.email.util.FileAndDirectoryUtils
import com.flowcrypt.email.util.OutgoingAttachmentUriValidator
import jakarta.mail.Message
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -119,6 +120,7 @@ object ProcessingOutgoingMessageInfoHelper {
val origFileUri = attachmentInfo.uri
var originalFileInputStream: InputStream? = null
if (origFileUri != null) {
OutgoingAttachmentUriValidator.requireAllowedUri(context, origFileUri)
originalFileInputStream = context.contentResolver.openInputStream(origFileUri)
} else if (attachmentInfo.rawData?.isNotEmpty() == true) {
originalFileInputStream = ByteArrayInputStream(attachmentInfo.rawData)
Expand Down Expand Up @@ -173,6 +175,7 @@ object ProcessingOutgoingMessageInfoHelper {
}

for (candidate in outgoingMsgInfo.forwardedAtts ?: emptyList()) {
candidate.uri?.let { OutgoingAttachmentUriValidator.requireAllowedUri(context, it) }
if (candidate.isEncryptionAllowed
&& outgoingMsgInfo.encryptionType === MessageEncryptionType.ENCRYPTED
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import com.flowcrypt.email.extensions.incrementSafely
import com.flowcrypt.email.extensions.toast
import com.flowcrypt.email.model.MessageEncryptionType
import com.flowcrypt.email.model.MessageType
import com.flowcrypt.email.ui.activity.fragment.CreateMessageFragmentArgs
import com.flowcrypt.email.ui.activity.fragment.dialog.ChoosePublicKeyDialogFragment
import com.flowcrypt.email.util.FileAndDirectoryUtils
import com.flowcrypt.email.util.FlavorSettings
Expand Down Expand Up @@ -73,16 +74,48 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
}

override fun onCreate(savedInstanceState: Bundle?) {
sanitizeIntentForNavigation(intent)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
(navController as? NavHostController)?.enableOnBackPressed(true)
isNavigationArrowDisplayed = true
val navGraph = navController.navInflater.inflate(R.navigation.create_msg_graph)
navController.setGraph(navGraph, intent.extras)
navController.setGraph(navGraph, createStartDestinationArgs(intent))
FileAndDirectoryUtils.cleanDir(File(cacheDir, Constants.DRAFT_CACHE_DIR))
applyInsetsToSupportEdgeToEdge()
}

override fun onNewIntent(intent: Intent) {
sanitizeIntentForNavigation(intent)
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
} else {
intent.extras?.let { CreateMessageFragmentArgs.fromBundle(it).toBundle() }
}
}

override fun onAccountInfoRefreshed(accountEntity: AccountEntity?) {
super.onAccountInfoRefreshed(accountEntity)
//check create a message from extra info when account didn't setup
Expand Down Expand Up @@ -111,6 +144,30 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
}

companion object {
private const val EXTRA_KEY_INCOMING_MESSAGE_INFO = "incomingMessageInfo"
private const val EXTRA_KEY_ATTACHMENTS = "attachments"
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
)
private val PUBLIC_INTENT_ACTIONS = setOf(
Intent.ACTION_VIEW,
Intent.ACTION_SENDTO,
Intent.ACTION_SEND,
Intent.ACTION_SEND_MULTIPLE
)

fun generateIntent(
context: Context?,
@MessageType messageType: Int,
Expand All @@ -121,11 +178,14 @@ class CreateMessageActivity : BaseActivity<ActivityCreateMessageBinding>(),
): Intent {
val intent = Intent(context, CreateMessageActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
intent.putExtra("incomingMessageInfo", msgInfo)
intent.putExtra("attachments", attachments)
intent.putExtra("messageType", messageType)
intent.putExtra("encryptedByDefault", msgEncryptionType == MessageEncryptionType.ENCRYPTED)
intent.putExtra("serviceInfo", serviceInfo)
intent.putExtra(EXTRA_KEY_INCOMING_MESSAGE_INFO, msgInfo)
intent.putExtra(EXTRA_KEY_ATTACHMENTS, attachments)
intent.putExtra(EXTRA_KEY_MESSAGE_TYPE, messageType)
intent.putExtra(
EXTRA_KEY_ENCRYPTED_BY_DEFAULT,
msgEncryptionType == MessageEncryptionType.ENCRYPTED
)
intent.putExtra(EXTRA_KEY_SERVICE_INFO, serviceInfo)
return intent
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
* Contributors: DenBond7
* Contributors: denbond7
*/

package com.flowcrypt.email.ui.activity.fragment.dialog
Expand All @@ -10,15 +10,14 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.navigation.fragment.navArgs
import com.flowcrypt.email.api.retrofit.response.base.Result
import com.flowcrypt.email.databinding.FragmentCreateOutgoingMessageBinding
import com.flowcrypt.email.extensions.launchAndRepeatWithLifecycle
import com.flowcrypt.email.extensions.androidx.fragment.app.navController
import com.flowcrypt.email.extensions.launchAndRepeatWithLifecycle
import com.flowcrypt.email.extensions.visible
import com.flowcrypt.email.jetpack.lifecycle.CustomAndroidViewModelFactory
import com.flowcrypt.email.jetpack.viewmodel.CreateOutgoingMessageViewModel
Expand Down Expand Up @@ -76,10 +75,10 @@ class CreateOutgoingMessageDialogFragment : BaseDialogFragment() {
navController?.navigateUp()
setFragmentResult(
args.requestKey,
bundleOf(
KEY_REQUEST_KEY to args.requestKey,
KEY_RESULT to it,
)
Bundle().apply {
putString(KEY_REQUEST_KEY, args.requestKey)
putSerializable(KEY_RESULT, it)
}
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
* Contributors: denbond7
*/

package com.flowcrypt.email.util

import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import com.flowcrypt.email.Constants
import java.io.File
import java.io.IOException
import java.util.Locale

/**
* Validates attachment URIs used by outgoing messages.
*
* Outgoing flows may only use file:// URIs staged inside FlowCrypt-controlled cache directories
* for the current compose/send session. Regular content:// URIs stay allowed so the system
* picker and external providers such as Google Drive continue to work.
*/
object OutgoingAttachmentUriValidator {
@Throws(IllegalArgumentException::class, IOException::class)
fun requireAllowedUri(context: Context, uri: Uri) {
when (uri.scheme?.lowercase(Locale.ROOT)) {
ContentResolver.SCHEME_CONTENT -> return
ContentResolver.SCHEME_FILE -> requireAllowedFileUri(context, uri)
else -> throw IllegalArgumentException("Unsupported attachment URI scheme: ${uri.scheme}")
}
}

@Throws(IOException::class)
private fun requireAllowedFileUri(context: Context, uri: Uri) {
val path = uri.path ?: throw IllegalArgumentException("Attachment file URI has no path")
val candidate = File(path).canonicalFile
val allowedRoots = listOf(
File(context.cacheDir, Constants.DRAFT_CACHE_DIR).canonicalFile,
File(context.cacheDir, Constants.ATTACHMENTS_CACHE_DIR).canonicalFile
)

if (allowedRoots.none { candidate.isInOrUnder(it) }) {
throw IllegalArgumentException("Attachment file URI points outside of FlowCrypt cache")
}
}

private fun File.isInOrUnder(root: File): Boolean {
return path == root.path || path.startsWith(root.path + File.separator)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* © 2016-present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com
* Contributors: denbond7
*/

package com.flowcrypt.email.util

import android.content.Context
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import com.flowcrypt.email.BuildConfig
import com.flowcrypt.email.Constants
import org.junit.Assert.assertThrows
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, minSdk = BuildConfig.MIN_SDK_VERSION)
class OutgoingAttachmentUriValidatorTest {
@get:Rule
val temporaryFolder: TemporaryFolder = TemporaryFolder()

private val context: Context
get() = ApplicationProvider.getApplicationContext()

@Test
fun rejectFileUriPointingToPrivateDatabasePath() {
val uri = Uri.parse("file:///data/data/${context.packageName}/databases/flowcrypt.db")

assertThrows(IllegalArgumentException::class.java) {
OutgoingAttachmentUriValidator.requireAllowedUri(context, uri)
}
}

@Test
fun rejectFileUriOutsideFlowCryptCache() {
val externalFile = temporaryFolder.newFile("foreign.txt")

assertThrows(IllegalArgumentException::class.java) {
OutgoingAttachmentUriValidator.requireAllowedUri(context, Uri.fromFile(externalFile))
}
}

@Test
fun allowFileUriInsideDraftCache() {
val draftDir = java.io.File(context.cacheDir, Constants.DRAFT_CACHE_DIR).apply {
mkdirs()
}
val stagedAttachment = java.io.File(draftDir, "allowed.txt").apply {
writeText("safe")
}

OutgoingAttachmentUriValidator.requireAllowedUri(context, Uri.fromFile(stagedAttachment))
}

@Test
fun allowContentUriFromExternalProvider() {
val contentUri = Uri.parse("content://com.google.android.apps.docs.storage/document/allowed")
OutgoingAttachmentUriValidator.requireAllowedUri(context, contentUri)
}
}
Loading