diff --git a/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt index 8a546ff..5cf8aef 100644 --- a/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/notifications/NotificationIntentsTest.kt @@ -13,9 +13,11 @@ import org.junit.Test import org.junit.runner.RunWith /** - * Locks in the notification deep-link contract: a message id round-trips build → parse, and intents - * for different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys - * on — so per-message notifications never collapse onto one shared PendingIntent. + * Locks in the notification deep-link contract: a message id round-trips build → parse, intents for + * different messages are distinct under [Intent.filterEquals] — the identity PendingIntent keys on — + * so per-message notifications never collapse onto one shared PendingIntent, and an `ACTION_OPEN_MESSAGE` + * intent that does not carry this app's own sender token is ignored so a foreign caller targeting the + * exported activity cannot drive the reader (#307). */ @RunWith(AndroidJUnit4::class) class NotificationIntentsTest { @@ -25,20 +27,31 @@ class NotificationIntentsTest { @Test fun message_id_round_trips_through_the_intent() { val id = "imap:user@example.com:INBOX:42" - assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + assertEquals(id, NotificationIntents.messageId(context, NotificationIntents.openMessage(context, id))) } @Test fun uri_hostile_ids_round_trip() { val id = "imap:user@example.com:[Gmail]/All Mail:7?&%#" - assertEquals(id, NotificationIntents.messageId(NotificationIntents.openMessage(context, id))) + assertEquals(id, NotificationIntents.messageId(context, NotificationIntents.openMessage(context, id))) } @Test fun other_intents_carry_no_message_id() { - assertNull(NotificationIntents.messageId(null)) - assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_MAIN))) - assertNull(NotificationIntents.messageId(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c")))) + assertNull(NotificationIntents.messageId(context, null)) + assertNull(NotificationIntents.messageId(context, Intent(Intent.ACTION_MAIN))) + assertNull(NotificationIntents.messageId(context, Intent(Intent.ACTION_VIEW, Uri.parse("mailto:a@b.c")))) + } + + @Test + fun open_message_intent_without_our_sender_token_is_ignored() { + // A hostile app can target the exported activity with our action (and a message id), but it + // cannot mint a PendingIntent attributed to us — so an ACTION_OPEN_MESSAGE intent that lacks our + // sender token must be ignored, while the genuine one (built by openMessage) still resolves. + val genuine = NotificationIntents.openMessage(context, "imap:a@b:INBOX:1") + val forged = Intent().setAction(genuine.action) + assertNull(NotificationIntents.messageId(context, forged)) + assertEquals("imap:a@b:INBOX:1", NotificationIntents.messageId(context, genuine)) } @Test diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 4a09b81..c71630c 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -122,7 +122,7 @@ class MainActivity : FragmentActivity() { private fun handleIntent(intent: Intent) { if (!IntentHandledMarker.markIfUnhandled(intent)) return IntentComposeParser.parse(intent)?.let { pendingCompose.value = it } - NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it } + NotificationIntents.messageId(this, intent)?.let { pendingOpenMessageId.value = it } } } diff --git a/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt index a27a40b..3c77c1e 100644 --- a/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt +++ b/app/src/main/kotlin/org/libremail/notifications/NotificationIntents.kt @@ -1,10 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.notifications +import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri +import androidx.core.content.IntentCompat import org.libremail.MainActivity +import org.libremail.reporting.AppLog /** * Builds and parses the intent behind a tapped per-message new-mail notification, keeping both sides @@ -14,20 +17,55 @@ import org.libremail.MainActivity * distinct URI every message's notification would collapse onto one FLAG_UPDATE_CURRENT PendingIntent * and always open the most-recently-notified message. The intent is explicit (component set), so the * private scheme needs no manifest intent-filter and adds no exported surface. + * + * [MainActivity] is nonetheless `exported="true"` (launcher / mailto: / share), so another app could + * still target it with an explicit `ACTION_OPEN_MESSAGE` intent and drive the reader to an arbitrary + * cached message id (#307). To honour only this app's own notification taps, [openMessage] attaches an + * unforgeable **sender token** — a PendingIntent whose creator package is stamped by the system — and + * [messageId] yields the id only when that token was created by us. A foreign caller carries no token + * (or one attributed to its own package), so its intent is ignored. */ object NotificationIntents { + private const val TAG = "NotificationIntents" private const val ACTION_OPEN_MESSAGE = "org.libremail.action.OPEN_MESSAGE" + private const val ACTION_SENDER_TOKEN = "org.libremail.action.SENDER_TOKEN" private const val EXTRA_MESSAGE_ID = "org.libremail.extra.MESSAGE_ID" + private const val EXTRA_SENDER_TOKEN = "org.libremail.extra.SENDER_TOKEN" fun openMessage(context: Context, messageId: String): Intent = Intent(context, MainActivity::class.java).apply { action = ACTION_OPEN_MESSAGE data = Uri.parse("libremail://message/${Uri.encode(messageId)}") putExtra(EXTRA_MESSAGE_ID, messageId) + putExtra(EXTRA_SENDER_TOKEN, senderToken(context)) flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } - /** The tapped message's id, or null for any other intent (launcher, mailto:, share, …). */ - fun messageId(intent: Intent?): String? = - intent?.takeIf { it.action == ACTION_OPEN_MESSAGE }?.getStringExtra(EXTRA_MESSAGE_ID) + /** + * The tapped message's id, or null for any other intent (launcher, mailto:, share, …). Also null + * for an `ACTION_OPEN_MESSAGE` intent that did not originate from this app's own notification — + * i.e. one whose [senderToken] is missing or was minted by another package — so a foreign caller + * targeting the exported [MainActivity] can never drive the reader (#307). + */ + fun messageId(context: Context, intent: Intent?): String? { + if (intent?.action != ACTION_OPEN_MESSAGE) return null + val token = IntentCompat.getParcelableExtra(intent, EXTRA_SENDER_TOKEN, PendingIntent::class.java) + if (token?.creatorPackage != context.packageName) { + AppLog.w(TAG, "Ignoring ACTION_OPEN_MESSAGE intent lacking this app's sender token") + return null + } + return intent.getStringExtra(EXTRA_MESSAGE_ID) + } + + /** + * An immutable PendingIntent used purely as unforgeable proof of origin: only this app can mint one + * whose [PendingIntent.getCreatorPackage] is our package. It is never sent — a package-scoped + * broadcast to a receiver we don't register — so firing it (which never happens) is a no-op. + */ + private fun senderToken(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_SENDER_TOKEN).setPackage(context.packageName), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) }