Skip to content

#3274 Prevent deep link injection into recoverable auth intent - #3275

Open
martgil wants to merge 14 commits into
masterfrom
issue-3274-prevent-deep-link-injection-on-recoverable-auth-intent
Open

martgil wants to merge 14 commits into
masterfrom
issue-3274-prevent-deep-link-injection-on-recoverable-auth-intent

Conversation

@martgil

@martgil martgil commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

This PR fixes a confused-deputy deep-link injection by validating the recoverableIntent before it's launched and moving the existing sanitizeIntentForNavigation() into BaseActivity for reuse.

close #3274


Tests (delete all except exactly one):

  • Tests added or updated

To be filled by reviewers

I have reviewed that this PR... (tick whichever items you personally focused on during this review):

  • addresses the issue it closes (if any)
  • code is readable and understandable
  • is accompanied with tests, or tests are not needed
  • is free of vulnerabilities

@martgil
martgil marked this pull request as draft September 8, 2026 09:59
@martgil
martgil marked this pull request as ready for review October 20, 2026 06:33
@martgil

martgil commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @sosnovsky and @DenBond7 sorry for the delay. This one is ready for a review. Thanks!

@DenBond7

Copy link
Copy Markdown
Collaborator

Hi @martgil. I haven't forgotten about this PR! I just need a bit of time to review the changes.

@martgil

martgil commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Thank you Den - I understand your situation, no worries at all.

martgil and others added 7 commits October 27, 2026 14:23
…deep-link-injection-on-recoverable-auth-intent
…ion arguments

The recoverable auth intent was passed through deep link extras, which can
be injected by third-party apps. Navigation 2.10 also ignores such extras for
URI deep links, so the legitimate reconnect flow was broken.

Now UserRecoverableAuthExceptionFragment requests a fresh token on Reconnect
and launches the intent provided by Google Play services via
UserRecoverableAuthException. The recoverableIntent navigation argument is removed.
… MainActivity

Navigation 2.10 handles explicit deep link extras (deepLinkIds, deepLinkExtras)
only when MainActivity was started by the app itself, so third-party apps can't
drive navigation there. The allowlist was weaker (it checked destinations, not
the caller) and broke UI tests that open MainActivity via NavDeepLinkBuilder.

- revert the deep link sanitizing in MainActivity
- drop the unused allowedDeepLinkDestinationIds parameter in BaseActivity
- replace UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest (the
  recoverableIntent argument doesn't exist anymore) with
  MainActivityDeepLinkInjectionTest that guards the Navigation trust check
Navigation determines the caller via Activity.getReferrer(), which prefers
referrer extras of getIntent(). Without setIntent() in onNewIntent() a spoofed
EXTRA_REFERRER from a previous external intent made the next external intent
with navigation deep link extras trusted.

- call setIntent(intent) before handling the new intent
- add a regression test with two sequential external intents
- wait for a possibly handled deep link in negative checks, as 'am start'
  adds FLAG_ACTIVITY_NEW_TASK and Navigation then restarts the task asynchronously
…nk injection test

- check that the second intent is delivered to the same MainActivity instance
  via onNewIntent(), so the test can't pass because a new activity was created
- cover a spoofed EXTRA_REFERRER (Uri) in addition to EXTRA_REFERRER_NAME

@DenBond7 DenBond7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review and follow-up changes for #3274

Four commits were added on top of fb829abe3:

  • f321688e1 fix: request Google recovery intent instead of passing it via navigation arguments
  • c11b00e4d fix: rely on Navigation trust check instead of deep link allowlist in MainActivity
  • dcf0b1e98 fix: prevent spoofed referrer reuse in MainActivity.onNewIntent()
  • ba343cd58 test: verify onNewIntent() delivery and cover Uri referrer in deep link injection test

TL;DR

  • The root cause is removed: nav_graph no longer has an argument of type android.content.Intent, so there is nothing an external app could inject and get launched by FlowCrypt.
  • The legitimate Google "Reconnect" flow was broken on master (it did nothing on tap). It works again now. Verified on a device.
  • The deep link allowlist in MainActivity was reverted. Navigation 2.10 already ignores explicit deep links from other apps, and the allowlist broke UI tests. A regression test now guards this behavior.
  • MainActivity.onNewIntent() now calls setIntent(). Without it, a spoofed referrer from a previous intent made the Navigation trust check pass for the next external intent. This was reproduced by a test and is also present on master.

1. What I found while reviewing the original fix

The first version of this PR validated recoverableIntent in UserRecoverableAuthExceptionFragment (package check plus FLAG_GRANT_* check) and added an allowlist of deep link destinations in MainActivity. While reviewing it I found:

  1. Navigation 2.10.x already has a trust check. In NavController.handleDeepLink() (navigation-runtime 2.10.2, shouldTrustIntent()), the deepLinkIds, deepLinkArgs and deepLinkExtras extras are used only when:

    • the intent has deepLinkIds, and
    • the activity was started by the app itself (callingPackage / referrer == own package; a spoofed EXTRA_REFERRER makes the intent untrusted).

    The report targets v1.6.4, which used Navigation 2.8.9. Current master uses 2.10.2.

  2. Because of that check, the legitimate Reconnect flow was broken. ErrorNotificationManager.getFixAuthIssueIntent() put recoverableIntent into deepLinkExtras of a URI deep link without deepLinkIds. Navigation 2.10 drops such extras, so args.recoverableIntent was always null and the Reconnect button silently returned. This was reproduced on master: after revoking access and tapping Fix → Reconnect, no com.google.android.gms activity was started.

  3. Validating the injected intent isn't a reliable defense:

    • The package-name check can be bypassed on devices without Google Play services, where any app can be installed under the com.google.android.gms package name.
    • AndroidX Activity Result handles ACTION_INTENT_SENDER_REQUEST and ACTION_REQUEST_PERMISSIONS separately: for these actions the checked component isn't what gets launched.
  4. The allowlist in MainActivity was weaker than the library check and broke tests:

    • It checked where an intent leads, not who sent it. An external app could still open messageDetailsFragment / threadDetailsFragment with forged MessageEntity / LocalFolder arguments.
    • It allowed [nav_graph, userRecoverableAuthExceptionFragment], which is exactly the PoC from the report.
    • ~36 UI test classes start MainActivity via TestGeneralUtil.genIntentForNavigationComponent() (NavDeepLinkBuilder) with destinations outside the allowlist (privateKeyDetailsFragment, mainSettingsFragment, mainSignInFragment, etc.). Their deep link extras would be stripped.

2. Changes

f321688e1: don't pass the recovery intent through navigation

Instead of trusting an Intent from navigation arguments, UserRecoverableAuthExceptionFragment requests a fresh token from Google Play services when the user taps Reconnect:

val result = withContext(Dispatchers.IO) {
  runCatching { EmailUtil.getGmailAccountToken(appContext, accountEntity) }
}
  • UserRecoverableAuthException: launch e.intent. This intent comes directly from Google Play services in our own process, never from extras.
  • Token received: access is already restored. The fragment runs the same logic as RESULT_OK (re-queues outbox messages with AUTH_FAILURE and opens the messages list). That logic was moved to onAccessRestored() to avoid duplication.
  • Any other error: shown in an info dialog.

Removed as no longer needed:

File Change
nav_graph.xml recoverableIntent argument of userRecoverableAuthExceptionFragment
ErrorNotificationManager recoverableIntent parameter and the manual deepLinkExtras
BaseFragment.showAuthIssueHint() recoverableIntent parameter
OpenStoreHelper, GmailApiHelper passing e.intent to the notification
MessagesListFragment, MessageDetailsFragment four identical auth-error branches merged into one
UserRecoverableAuthExceptionFragment isValidGoogleRecoverableAuthIntent(), package constant, navArgs

Why this approach: it removes the vulnerable input instead of validating it. With no Intent-typed argument in the graph, there is nothing to inject. It also fixes the broken Reconnect flow and makes the package spoofing and Activity Result special-action concerns irrelevant.

c11b00e4d: rely on the Navigation trust check in MainActivity

  • MainActivity is reverted to its master state: no sanitizing, no allowlist. The setIntent() call was reverted here too, which turned out to be wrong; it is restored in the next commit.
  • BaseActivity.sanitizeIntentForNavigation(): the allowedDeepLinkDestinationIds parameter was removed because nothing uses it. The function is still used by CreateMessageActivity.

Why: Navigation's caller-based check is stronger than a destination allowlist and doesn't break internal NavDeepLinkBuilder usage (notifications, UI tests). The trade-off is that we depend on library behavior, so it is covered by a test (see below).

dcf0b1e98: don't let a previous intent's referrer be reused

Found during review of the commits above. Navigation's shouldTrustIntent() checks for referrer extras only in the new intent and determines the caller via Activity.getReferrer(). getReferrer() first returns EXTRA_REFERRER / EXTRA_REFERRER_NAME from getIntent(), and only then the real caller. Without setIntent() in onNewIntent(), getIntent() still holds the previous intent:

  1. An external app starts MainActivity with EXTRA_REFERRER_NAME = android-app://com.flowcrypt.email. Navigation treats this intent as untrusted, but it stays in getIntent().
  2. The same app sends a second intent with deepLinkIds / deepLinkExtras and no referrer extras. It is delivered via onNewIntent().
  3. Navigation sees no referrer extras in the new intent, while getReferrer() returns the spoofed value from step 1. The deep link is trusted and handled.

The fix is to call setIntent(intent) before super.onNewIntent(intent) and handleDeepLink(intent), so getReferrer() reads the current intent and falls back to the real caller. The original version of this PR had this call; it was removed in c11b00e4d by mistake.

3. Tests

  • Removed UserRecoverableAuthExceptionFragmentDeepLinkInjectionTest. The argument it tested no longer exists. Its negative assertions (intended(not(hasComponent(...)))) also didn't verify the absence of the attacker intent: intended() defaults to times(1).

  • Added MainActivityDeepLinkInjectionTest:

    • testExplicitDeepLinkFromAnotherAppIsIgnored: am start from the shell (com.android.shell, uid 2000) sends deepLinkIds = [nav_graph, mainSettingsFragment]. Expected result: the messages list, not settings.
    • testExplicitDeepLinkFromAnotherAppIsIgnoredAfterSpoofedReferrerName / ...ReferrerUri: two sequential shell intents: a spoofed referrer first (EXTRA_REFERRER_NAME as a string or EXTRA_REFERRER as a Uri), then deepLinkIds. The tests also check that the second intent was delivered to the same MainActivity instance (via onNewIntent()), so they can't pass because a new activity was created instead. Both fail without setIntent(): settings are opened.
    • testExplicitDeepLinkFromAppItselfIsHandled: control case. The same destination via NavDeepLinkBuilder, started by the app itself, opens settings. This proves the first test doesn't pass just because the deep link is broken.

    The shell is used as the "third-party app" on purpose. The test APK can't play that role: it runs in the app process with the app UID, so the system treats it as the app itself.

    am start adds FLAG_ACTIVITY_NEW_TASK without CLEAR_TASK, so a handled deep link restarts the task asynchronously. The negative checks therefore wait up to 5 s for settings to appear before asserting the messages list.

4. Verification

Check Result
compileConsumerDebugKotlin, compileConsumerUiTestsAndroidTestKotlin ✅
MainActivityDeepLinkInjectionTest on ci-emulator ✅ 4/4
Same test without setIntent() in MainActivity ❌ both spoofed referrer tests fail (settings opened), confirms the bypass
Google Reconnect on a device (access revoked → Fix → Reconnect) ✅ Google consent screen opens, app returns to messages list
Opening a message from a new-message notification (NavDeepLinkBuilder) ✅
Same Reconnect scenario on master ❌ nothing happens (confirms the regression)

@DenBond7

Copy link
Copy Markdown
Collaborator

@martgil thanks a lot for your work on this! Hardening MainActivity was the right call, and your setIntent() in onNewIntent() turned out to be important. While reviewing the PR I had to make a few changes on top of your commits. Please look at the review comment below. Thanks again!

@sosnovsky, please review the latest changes.

@martgil

martgil commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

@martgil thanks a lot for your work on this! Hardening MainActivity was the right call, and your setIntent() in onNewIntent() turned out to be important. While reviewing the PR I had to make a few changes on top of your commits. Please look at the review comment below. Thanks again!

Thanks @DenBond7, this closes the injection path cleanly. I reviewed the diff and found no blockers. Two small follow-ups for later: in UserRecoverableAuthExceptionFragment.requestGoogleAccessRecovery, rethrow CancellationException around the runCatching block so coroutine cancellation is not swallowed; and consider extending sanitizeIntentForNavigation in CreateMessageActivity to cover the other included dialog destinations, not only createOutgoingMessageDialogFragment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improved Validation of Deep-Link Navigation Handling

3 participants