fix(notifications): verify notification-tap origin before opening a message (#307) #434

Merged
JMR-dev merged 2 commits from refactor-307-domain-platform-nits into main 2026-07-08 19:45:50 +00:00
3 changed files with 63 additions and 12 deletions
@@ -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
@@ -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 }
}
}
@@ -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,
)
}