Repository navigation
Conversation
…recoverable-auth-intent
…recoverable-auth-intent
|
Hi @sosnovsky and @DenBond7 sorry for the delay. This one is ready for a review. Thanks! |
…recoverable-auth-intent
|
Hi @martgil. I haven't forgotten about this PR! I just need a bit of time to review the changes. |
…recoverable-auth-intent
|
Thank you Den - I understand your situation, no worries at all. |
…deep-link-injection-on-recoverable-auth-intent
…ination injection
…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
left a comment
There was a problem hiding this comment.
Review and follow-up changes for #3274
Four commits were added on top of fb829abe3:
f321688e1fix: request Google recovery intent instead of passing it via navigation argumentsc11b00e4dfix: rely on Navigation trust check instead of deep link allowlist in MainActivitydcf0b1e98fix: prevent spoofed referrer reuse in MainActivity.onNewIntent()ba343cd58test: verify onNewIntent() delivery and cover Uri referrer in deep link injection test
TL;DR
- The root cause is removed:
nav_graphno longer has an argument of typeandroid.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
MainActivitywas 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 callssetIntent(). 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 onmaster.
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:
-
Navigation 2.10.x already has a trust check. In
NavController.handleDeepLink()(navigation-runtime 2.10.2,shouldTrustIntent()), thedeepLinkIds,deepLinkArgsanddeepLinkExtrasextras are used only when:- the intent has
deepLinkIds, and - the activity was started by the app itself (
callingPackage/referrer== own package; a spoofedEXTRA_REFERRERmakes the intent untrusted).
The report targets v1.6.4, which used Navigation 2.8.9. Current
masteruses 2.10.2. - the intent has
-
Because of that check, the legitimate Reconnect flow was broken.
ErrorNotificationManager.getFixAuthIssueIntent()putrecoverableIntentintodeepLinkExtrasof a URI deep link withoutdeepLinkIds. Navigation 2.10 drops such extras, soargs.recoverableIntentwas alwaysnulland the Reconnect button silently returned. This was reproduced onmaster: after revoking access and tapping Fix → Reconnect, nocom.google.android.gmsactivity was started. -
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.gmspackage name. - AndroidX Activity Result handles
ACTION_INTENT_SENDER_REQUESTandACTION_REQUEST_PERMISSIONSseparately: for these actions the checked component isn't what gets launched.
- The package-name check can be bypassed on devices without Google Play services, where any app can be installed under the
-
The allowlist in
MainActivitywas 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/threadDetailsFragmentwith forgedMessageEntity/LocalFolderarguments. - It allowed
[nav_graph, userRecoverableAuthExceptionFragment], which is exactly the PoC from the report. - ~36 UI test classes start
MainActivityviaTestGeneralUtil.genIntentForNavigationComponent()(NavDeepLinkBuilder) with destinations outside the allowlist (privateKeyDetailsFragment,mainSettingsFragment,mainSignInFragment, etc.). Their deep link extras would be stripped.
- It checked where an intent leads, not who sent it. An external app could still open
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: launche.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 withAUTH_FAILUREand opens the messages list). That logic was moved toonAccessRestored()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
MainActivityis reverted to itsmasterstate: no sanitizing, no allowlist. ThesetIntent()call was reverted here too, which turned out to be wrong; it is restored in the next commit.BaseActivity.sanitizeIntentForNavigation(): theallowedDeepLinkDestinationIdsparameter was removed because nothing uses it. The function is still used byCreateMessageActivity.
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:
- An external app starts
MainActivitywithEXTRA_REFERRER_NAME = android-app://com.flowcrypt.email. Navigation treats this intent as untrusted, but it stays ingetIntent(). - The same app sends a second intent with
deepLinkIds/deepLinkExtrasand no referrer extras. It is delivered viaonNewIntent(). - 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 totimes(1). -
Added
MainActivityDeepLinkInjectionTest:testExplicitDeepLinkFromAnotherAppIsIgnored:am startfrom the shell (com.android.shell, uid 2000) sendsdeepLinkIds = [nav_graph, mainSettingsFragment]. Expected result: the messages list, not settings.testExplicitDeepLinkFromAnotherAppIsIgnoredAfterSpoofedReferrerName/...ReferrerUri: two sequential shell intents: a spoofed referrer first (EXTRA_REFERRER_NAMEas a string orEXTRA_REFERRERas aUri), thendeepLinkIds. The tests also check that the second intent was delivered to the sameMainActivityinstance (viaonNewIntent()), so they can't pass because a new activity was created instead. Both fail withoutsetIntent(): settings are opened.testExplicitDeepLinkFromAppItselfIsHandled: control case. The same destination viaNavDeepLinkBuilder, 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 startaddsFLAG_ACTIVITY_NEW_TASKwithoutCLEAR_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) |
|
@martgil thanks a lot for your work on this! Hardening @sosnovsky, please review the latest changes. |
…recoverable-auth-intent
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. |
This PR fixes a confused-deputy deep-link injection by validating the
recoverableIntentbefore it's launched and moving the existingsanitizeIntentForNavigation()intoBaseActivityfor reuse.close #3274
Tests (delete all except exactly one):
To be filled by reviewers
I have reviewed that this PR... (tick whichever items you personally focused on during this review):