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
JMR-dev commented 2026-07-08 12:19:23 +00:00 (Migrated from github.com)

Closes #307 — Phase-3 domain/platform below-cut review nits.

Two nits were listed. Nit 1 is fixed (with a test); Nit 2 is intentionally deferred with the reasoning below rather than forced.


Nit 1 — MainActivity/NotificationIntents: exported activity consumed ACTION_OPEN_MESSAGE without caller verification — FIXED

MainActivity is exported="true" (launcher / mailto: / share). The per-message notification intent is explicit and carries no manifest intent-filter, but a hostile app can still craft an explicit intent at the exported component with action = ACTION_OPEN_MESSAGE + a message id and drive the reader to an arbitrary cached message. (Impact is limited — it only makes our app render an already-cached message in-app, and app-lock gates it — hence "below the cut". No data exfiltration.)

Implemented the maintainer's second option — "trust only the app's own PendingIntent":

  • openMessage(...) now attaches an unforgeable sender-token PendingIntent. A PendingIntent's creator package is stamped by the system and cannot be forged: only this app can mint one whose getCreatorPackage() is our package. The token is immutable and never sent (a package-scoped broadcast to no receiver), so it adds no new attack surface.
  • messageId(context, intent) yields the id only when that token is present and was created by us; otherwise it returns null. A foreign caller carries no token (or one attributed to its own package), so its intent is ignored.
  • The rejection path logs PII-free via AppLog.w (no message id, which is PII).

Why this can't be done more simply: notification taps don't go through startActivityForResult, so getCallingPackage() is null even for our own legitimate flow; and getReferrer() is caller-overridable (EXTRA_REFERRER), so it isn't a trustworthy check. The nested sender-token PendingIntent is the standard robust way to verify "this intent was built by us". Change is contained to the existing NotificationIntents build/parse contract plus the single MainActivity call site — mailto:/share are deliberately left open (that's the whole point of an email client).

Test: NotificationIntentsTest gains open_message_intent_without_our_sender_token_is_ignored (a same-action, token-less intent is rejected while the genuine one still resolves). Existing round-trip cases move to the new messageId(context, intent) signature. This is an instrumented test because the behaviour hinges on PendingIntent.getCreatorPackage(), which needs the real framework — matching the existing NotificationIntentsTest, which is instrumented for the same reason (real Intent/Uri/PendingIntent). It compiles locally (compileDebugAndroidTestKotlin) and runs in CI's E2E matrix.


Nit 2 — MailNotifier: messageId.hashCode() notification id can collide — DEFERRED (not fixed here), with reasoning

The finding is technically valid but I did not apply the suggested "persisted per-account counter" in this below-cut cleanup, because forcing it in would be disproportionate and would trade away a documented property for a negligible gain:

  1. No stateless function can be collision-free. Notification ids are 32-bit Int and message ids are arbitrary strings — by pigeonhole, any String → Int mapping has collisions. So only a stateful (persisted) scheme can literally satisfy "collision-free"; no small tweak to the current hashCode() derivation qualifies, and shipping a cosmetic non-fix would be worse than leaving it.

  2. The practical probability is ~1e-8 per notification. A collision only silently replaces a notification when a new message's id happens to equal a currently-displayed, unacknowledged notification's id. That active set is tiny (typically well under ~50), so per-event P ≈ 50 / 2^32 ≈ 1.2e-8 — i.e. on the order of 0.1% of users might ever see a single dropped notification, and the mail itself is still in the app.

  3. The suggested fix is disproportionate/risky for a P4 below-cut nit. MailNotifier is currently a clean, dependency-free @Singleton (just a Context). A persisted per-account counter means either a Room migration (the natural home is the per-account settings table — the exact "storage/migration" category we treat with extra care) or a new DataStore/prefs seam, plus threading async persisted I/O into the notification-posting path and making notifyNewMail suspend. That's a lot of new failure surface for a ~1e-8 issue.

  4. A naive persisted counter also changes documented semantics. The current id is deliberately stable per message ("a later batch never overwrites an earlier, still-unacknowledged one"); a monotonic counter is stable per notify-event, not per message, so re-notifying the same message would duplicate rather than update in place. (In practice re-notify ~never happens — MailSyncer filters on existingIds and serialises syncs — so this isn't an observed regression, but it does abandon the property the code documents.) An in-memory counter would be strictly worse than the status quo (it restarts low after process death and would collide with still-showing pre-death notifications).

Recommendation: if this is worth pursuing, do it as its own tracked change (not folded into a nits-cleanup PR): a per-account monotonic counter persisted in a small dedicated DataStore (avoids a Room migration), assigned once per notify-batch (single edit, not one write per message), skipping the account's summary id. Happy to take that as a separate ticket if you'd like it prioritised over "accept as-is".


Gate

Local fast gate green (assembleDebug + testDebugUnitTest + jacocoTestCoverageVerification (floor 0.84 held) + compileDebugAndroidTestKotlin + lintDebug + ktlintCheck + detekt) — BUILD SUCCESSFUL. The instrumented NotificationIntentsTest compiles locally and runs in CI's matrix.

Closes #307 — Phase-3 domain/platform below-cut review nits. Two nits were listed. **Nit 1 is fixed** (with a test); **Nit 2 is intentionally deferred** with the reasoning below rather than forced. --- ## Nit 1 — `MainActivity`/`NotificationIntents`: exported activity consumed `ACTION_OPEN_MESSAGE` without caller verification — **FIXED** `MainActivity` is `exported="true"` (launcher / `mailto:` / share). The per-message notification intent is explicit and carries no manifest intent-filter, but a hostile app can still craft an explicit intent at the exported component with `action = ACTION_OPEN_MESSAGE` + a message id and drive the reader to an arbitrary cached message. (Impact is limited — it only makes *our* app render an already-cached message in-app, and app-lock gates it — hence "below the cut". No data exfiltration.) Implemented the maintainer's second option — **"trust only the app's own PendingIntent"**: - `openMessage(...)` now attaches an **unforgeable sender-token `PendingIntent`**. A `PendingIntent`'s creator package is stamped by the system and cannot be forged: only this app can mint one whose `getCreatorPackage()` is our package. The token is immutable and never sent (a package-scoped broadcast to no receiver), so it adds no new attack surface. - `messageId(context, intent)` yields the id **only** when that token is present *and* was created by us; otherwise it returns `null`. A foreign caller carries no token (or one attributed to its own package), so its intent is ignored. - The rejection path logs PII-free via `AppLog.w` (no message id, which is PII). Why this can't be done more simply: notification taps don't go through `startActivityForResult`, so `getCallingPackage()` is `null` even for our own legitimate flow; and `getReferrer()` is caller-overridable (`EXTRA_REFERRER`), so it isn't a trustworthy check. The nested sender-token `PendingIntent` is the standard robust way to verify "this intent was built by us". Change is contained to the existing `NotificationIntents` build/parse contract plus the single `MainActivity` call site — `mailto:`/share are deliberately left open (that's the whole point of an email client). **Test:** `NotificationIntentsTest` gains `open_message_intent_without_our_sender_token_is_ignored` (a same-action, token-less intent is rejected while the genuine one still resolves). Existing round-trip cases move to the new `messageId(context, intent)` signature. This is an instrumented test because the behaviour hinges on `PendingIntent.getCreatorPackage()`, which needs the real framework — matching the existing `NotificationIntentsTest`, which is instrumented for the same reason (real `Intent`/`Uri`/`PendingIntent`). It compiles locally (`compileDebugAndroidTestKotlin`) and runs in CI's E2E matrix. --- ## Nit 2 — `MailNotifier`: `messageId.hashCode()` notification id can collide — **DEFERRED (not fixed here), with reasoning** The finding is technically valid but I did **not** apply the suggested "persisted per-account counter" in this below-cut cleanup, because forcing it in would be disproportionate and would trade away a documented property for a negligible gain: 1. **No *stateless* function can be collision-free.** Notification ids are 32-bit `Int` and message ids are arbitrary strings — by pigeonhole, any `String → Int` mapping has collisions. So only a *stateful* (persisted) scheme can literally satisfy "collision-free"; no small tweak to the current `hashCode()` derivation qualifies, and shipping a cosmetic non-fix would be worse than leaving it. 2. **The practical probability is ~1e-8 per notification.** A collision only silently replaces a notification when a *new* message's id happens to equal a *currently-displayed, unacknowledged* notification's id. That active set is tiny (typically well under ~50), so per-event P ≈ 50 / 2^32 ≈ 1.2e-8 — i.e. on the order of 0.1% of users might *ever* see a single dropped notification, and the mail itself is still in the app. 3. **The suggested fix is disproportionate/risky for a P4 below-cut nit.** `MailNotifier` is currently a clean, dependency-free `@Singleton` (just a `Context`). A persisted per-account counter means either a **Room migration** (the natural home is the per-account settings table — the exact "storage/migration" category we treat with extra care) or a new DataStore/prefs seam, plus threading async persisted I/O into the notification-posting path and making `notifyNewMail` `suspend`. That's a lot of new failure surface for a ~1e-8 issue. 4. **A naive persisted counter also changes documented semantics.** The current id is deliberately *stable per message* ("a later batch never overwrites an earlier, still-unacknowledged one"); a monotonic counter is stable per *notify-event*, not per message, so re-notifying the same message would duplicate rather than update in place. (In practice re-notify ~never happens — `MailSyncer` filters on `existingIds` and serialises syncs — so this isn't an *observed* regression, but it does abandon the property the code documents.) An in-memory counter would be strictly *worse* than the status quo (it restarts low after process death and would collide with still-showing pre-death notifications). **Recommendation:** if this is worth pursuing, do it as its own tracked change (not folded into a nits-cleanup PR): a per-account monotonic counter persisted in a small dedicated DataStore (avoids a Room migration), assigned once per notify-*batch* (single edit, not one write per message), skipping the account's summary id. Happy to take that as a separate ticket if you'd like it prioritised over "accept as-is". --- ## Gate Local fast gate green (`assembleDebug` + `testDebugUnitTest` + `jacocoTestCoverageVerification` (floor 0.84 held) + `compileDebugAndroidTestKotlin` + `lintDebug` + `ktlintCheck` + `detekt`) — `BUILD SUCCESSFUL`. The instrumented `NotificationIntentsTest` compiles locally and runs in CI's matrix.
mergify[bot] commented 2026-07-08 12:40:17 +00:00 (Migrated from github.com)

Merge Queue Status

  • ✅ Entered queue — 2026-07-08 12:40 UTC · Rule: default · triggered by merge protections
  • ❌ Checks failed · in-place
  • 🚫 Left the queue — 2026-07-08 13:52 UTC · at 1ee6366bec9259a1864721519295e1d5e7262f38

This pull request spent 1 hour 12 minutes 20 seconds in the queue, with no time running CI.

Waiting for
  • check-success = CI passed
  • any of: [🛡 GitHub branch protection]
    • check-neutral = CI passed
    • check-skipped = CI passed
    • check-success = CI passed
  • any of: [🛡 GitHub repository ruleset rule main]
    • check-neutral = @github-actions/CI passed
    • check-skipped = @github-actions/CI passed
    • check-success = @github-actions/CI passed
All conditions

Reason

The merge conditions cannot be satisfied due to failing checks

Hint

You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a @mergifyio queue comment.

Requeued — the merge queue status continues in this comment ↓.

<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "dequeued", "queue_rule_name": "default", "queued_at": "2026-07-08T12:40:15.093972+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 12:40 UTC` · Rule: `default` · triggered by merge protections - ❌ **Checks failed** · in-place - 🚫 **Left the queue** — `2026-07-08 13:52 UTC` · at `1ee6366bec9259a1864721519295e1d5e7262f38` This pull request spent **1 hour 12 minutes 20 seconds** in the queue, with no time running CI. <details> <summary><strong>Waiting for</strong></summary> - [ ] `check-success = CI passed` - [ ] any of: [🛡 GitHub branch protection] - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [ ] `check-success = CI passed` - [ ] any of: [🛡 GitHub repository ruleset rule `main`] - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` - [ ] `check-success = @github-actions/CI passed` </details> <details> <summary>All conditions</summary> - [ ] `check-success = CI passed` - [ ] any of [🛡 GitHub branch protection]: - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [ ] `check-success = CI passed` - [ ] any of [🛡 GitHub repository ruleset rule `main`]: - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` - [ ] `check-success = @github-actions/CI passed` - `-conflict` - [X] #434 - `-draft` - [X] #434 - [X] `base = main` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #434 - `label != broken` - [X] #434 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` </details> ## Reason The merge conditions cannot be satisfied due to failing checks ## Hint You may have to fix your CI before adding the pull request to the queue again. If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again. If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a `@mergifyio queue` comment. Requeued — the merge queue status continues in [this comment ↓](https://github.com/JMR-dev/LibreMail/pull/434#issuecomment-4918157647).
mergify[bot] commented 2026-07-08 18:46:13 +00:00 (Migrated from github.com)

Merge Queue Status

  • 🟠 Waiting for queue conditions
  • ⏳ Enter queue
  • ⏳ Run checks
  • ⏳ Merge
Required conditions to enter a queue
  • -closed [📌 queue requirement]
  • -conflict [📌 queue requirement]
  • -draft [📌 queue requirement]
  • any of [📌 queue -> configuration change requirements]:
    • -mergify-configuration-changed
    • check-success = Configuration changed
  • any of [📌 queue requirement]:
    • check-neutral = Mergify Merge Protections
    • check-skipped = Mergify Merge Protections
    • check-success = Mergify Merge Protections
  • any of [🔀 queue conditions]:
    • all of [📌 queue conditions of queue rule default]:
      • -conflict
      • -draft
      • base = main
      • check-success = CI passed
      • github-review-approved [🛡 GitHub repository ruleset rule main]
      • label != broken
      • any of [🛡 GitHub branch protection]:
        • check-success = Debug build
        • check-neutral = Debug build
        • check-skipped = Debug build
      • any of [🛡 GitHub branch protection]:
        • check-success = Unit tests
        • check-neutral = Unit tests
        • check-skipped = Unit tests
      • any of [🛡 GitHub branch protection]:
        • check-success = CI passed
        • check-neutral = CI passed
        • check-skipped = CI passed
      • any of [🛡 GitHub repository ruleset rule main]:
        • check-success = @github-actions/CI passed
        • check-neutral = @github-actions/CI passed
        • check-skipped = @github-actions/CI passed
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "waiting", "queue_rule_name": null, "queued_at": null, "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - 🟠 **Waiting for queue conditions** - ⏳ Enter queue - ⏳ Run checks - ⏳ Merge <details> <summary>Required conditions to enter a queue</summary> - [X] `-closed` [📌 queue requirement] - [X] `-conflict` [📌 queue requirement] - [X] `-draft` [📌 queue requirement] - [X] any of [📌 queue -> configuration change requirements]: - [X] `-mergify-configuration-changed` - [ ] `check-success = Configuration changed` - [X] any of [📌 queue requirement]: - [X] `check-neutral = Mergify Merge Protections` - [ ] `check-skipped = Mergify Merge Protections` - [ ] `check-success = Mergify Merge Protections` - [X] any of [🔀 queue conditions]: - [X] all of [📌 queue conditions of queue rule `default`]: - [X] `-conflict` - [X] `-draft` - [X] `base = main` - [X] `check-success = CI passed` - [X] `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] `label != broken` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
JMR-dev commented 2026-07-08 18:50:13 +00:00 (Migrated from github.com)

@Mergifyio refresh

@Mergifyio refresh
mergify[bot] commented 2026-07-08 18:50:26 +00:00 (Migrated from github.com)

refresh

✅ Pull request refreshed

> refresh #### ✅ Pull request refreshed
mergify[bot] commented 2026-07-08 19:01:28 +00:00 (Migrated from github.com)

queue

❌ The pull request has been removed from the queue default

The merge conditions cannot be satisfied due to failing checks.

You can take a look at Mergify Merge Queue check runs for more details about the failure.

> queue #### ❌ The pull request has been removed from the queue `default` <details> The merge conditions cannot be satisfied due to failing checks. You can take a look at `Mergify Merge Queue` check runs for more details about the failure. </details>
JMR-dev commented 2026-07-08 19:04:24 +00:00 (Migrated from github.com)

@Mergifyio requeue

@Mergifyio requeue
mergify[bot] commented 2026-07-08 19:04:42 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 41 minutes 12 seconds in the queue, including 27 minutes 14 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T19:04:42.172537+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 19:04 UTC` · Rule: `default` · triggered by @JMR-dev with the [`@mergifyio queue` command](https://github.com/JMR-dev/LibreMail/pull/434#issuecomment-4918297090) - ✅ **Checks passed** · on draft #456 - ✅ **Merged** — `2026-07-08 19:45 UTC` · at `1ee6366bec9259a1864721519295e1d5e7262f38` · merge This pull request spent **41 minutes 12 seconds** in the queue, including **27 minutes 14 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #434 - [X] #436 - `-draft` - [X] #434 - [X] #436 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #434 - [X] #436 - `label != broken` - [X] #434 - [X] #436 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.