6eb341d43801

fix: authenticate the picker→MainActivity new-entry handoff (v2.10.17)

Security scan finding F3 (low), slice 2 of the verified-fix queue. MainActivity is the exported launcher, so any app could raise EXTRA_OPEN_NEW_ENTRY and pre-fill the new-entry editor with an attacker-chosen url/username/email. The legitimate sender is the non-exported AutofillEntryPickerActivity — now proven with a one-time in-memory token (issueNewEntryToken/consumeNewEntryToken in AutofillIntents): single-use, and a wrong token does not burn the pending one, so a spoofed launch can fail but not DoS the real handoff. Tests: AutofillNewEntryTokenTest (3).

time
2026-08-03 23:43 UTC · 2 months ago
machine
koh2
parent
0413521f37b3
5 files VERSION+1 −1app/src/main/java/com/peachpasswords/MainActivity.kt+3 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+1 −0app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+27 −0app/src/test/java/com/peachpasswords/autofill/AutofillNewEntryTokenTest.kt+36 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.161+2.10.17

view file

app/src/main/java/com/peachpasswords/MainActivity.kt+3 −0
@@ -162,6 +162,9 @@162162  */163163 private fun MainActivity.forwardNewEntryRequest(intent: Intent) {164164     if (!intent.getBooleanExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, false)) return165+    // F3: MainActivity is exported; only accept the prefill payload when the166+    // one-time token proves the launch came from the in-process picker.167+    if (!AutofillIntents.consumeNewEntryToken(intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN))) return165168     autofillNav.requestNewEntry(166169         url = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL),167170         username = intent.getStringExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME),

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+1 −0
@@ -363,6 +363,7 @@363363         val targetOrigin = searchText?.takeIf { it.isNotBlank() }?.let(::extractDomain) ?: parsed.origin364364         val intent = Intent(this, MainActivity::class.java).apply {365365             putExtra(AutofillIntents.EXTRA_OPEN_NEW_ENTRY, true)366+            putExtra(AutofillIntents.EXTRA_NEW_ENTRY_TOKEN, AutofillIntents.issueNewEntryToken())366367             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_URL, targetOrigin)367368             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_USERNAME, typedUsername)368369             putExtra(AutofillIntents.EXTRA_NEW_ENTRY_EMAIL, typedEmail)

view file

app/src/main/java/com/peachpasswords/autofill/AutofillIntents.kt+27 −0
@@ -46,9 +46,36 @@4646     const val EXTRA_NEW_ENTRY_URL = "peach.autofill.newEntryUrl"4747     const val EXTRA_NEW_ENTRY_USERNAME = "peach.autofill.newEntryUsername"4848     const val EXTRA_NEW_ENTRY_EMAIL = "peach.autofill.newEntryEmail"49+    /**50+     * F3: one-time token proving a new-entry launch really came from the51+     * in-process picker. MainActivity is exported (launcher), so without this52+     * any app could pre-fill the new-entry editor with attacker-chosen53+     * url/username/email.54+     */55+    const val EXTRA_NEW_ENTRY_TOKEN = "peach.autofill.newEntryToken"4956 5057     private const val REQ_CHIP = 0xC0DE5158     private const val REQ_PICKER = 0xBEEF59+60+    @Volatile private var pendingNewEntryToken: String? = null61+62+    /** F3: the picker calls this once per launch; the token lives only in memory. */63+    fun issueNewEntryToken(): String {64+        val token = java.util.UUID.randomUUID().toString()65+        pendingNewEntryToken = token66+        return token67+    }68+69+    /**70+     * F3: single-use check. A wrong token does NOT burn the pending one, so a71+     * racing spoofed intent can only fail, not DoS the real handoff.72+     */73+    fun consumeNewEntryToken(token: String?): Boolean {74+        val expected = pendingNewEntryToken ?: return false75+        if (token != expected) return false76+        pendingNewEntryToken = null77+        return true78+    }5279 5380     /** Required attribution for an inline suggestion; tapping it is a no-op. */5481     fun noopChipIntent(context: Context): PendingIntent {

view file

app/src/test/java/com/peachpasswords/autofill/AutofillNewEntryTokenTest.kt+36 −0
@@ -0,0 +1,36 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertNotEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * F3: the picker→MainActivity new-entry handoff is authenticated with a10+ * one-time in-memory token. MainActivity is exported, so any app can raise11+ * EXTRA_OPEN_NEW_ENTRY; only the token holder's payload may pre-fill.12+ */13+class AutofillNewEntryTokenTest {14+15+    @Test fun issued_token_is_accepted_exactly_once() {16+        val token = AutofillIntents.issueNewEntryToken()17+        assertTrue(AutofillIntents.consumeNewEntryToken(token))18+        assertFalse("token must be single-use", AutofillIntents.consumeNewEntryToken(token))19+    }20+21+    @Test fun wrong_or_missing_tokens_are_rejected_without_burning_the_pending_one() {22+        val token = AutofillIntents.issueNewEntryToken()23+        assertFalse(AutofillIntents.consumeNewEntryToken(null))24+        assertFalse(AutofillIntents.consumeNewEntryToken("attacker-guess"))25+        // The real handoff still succeeds after spoofed attempts.26+        assertTrue(AutofillIntents.consumeNewEntryToken(token))27+    }28+29+    @Test fun fresh_issue_supersedes_the_previous_token() {30+        val first = AutofillIntents.issueNewEntryToken()31+        val second = AutofillIntents.issueNewEntryToken()32+        assertNotEquals(first, second)33+        assertFalse(AutofillIntents.consumeNewEntryToken(first))34+        assertTrue(AutofillIntents.consumeNewEntryToken(second))35+    }36+}

view file