1009fd2d3ebd

fix: Block unverified web autofill credential release (v2.14.73)

Preserve a fail-closed trust decision from origin resolution through every autofill response, picker, and save sink. Repair the malicious companion form so device automation proves the denial path is exercised. Verification: focused regression tests passed; full JVM suite passed; lintDebug passed after a transient FIR analyzer retry; assembleRelease passed.

time
2026-08-31 12:27 UTC · 1 month ago
machine
koh2
parent
4b3185755696
10 files VERSION+1 −1app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+6 −0app/src/androidTest/java/com/peachpasswords/qa/QaSpoofedWebViewActivity.java+38 −16app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+2 −0app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+13 −1app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+27 −6app/src/main/java/com/peachpasswords/autofill/AutofillSave.kt+1 −0app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+11 −1app/src/test/java/com/peachpasswords/autofill/AutofillOriginReleaseTest.kt+41 −0app/src/test/java/com/peachpasswords/autofill/AutofillSaveTest.kt+9 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.721+2.14.73

view file

app/src/androidTest/java/com/peachpasswords/qa/QaFormTwoFactorChipTest.kt+6 −0
@@ -52,10 +52,16 @@5252         val clipboard = InstrumentationRegistry.getInstrumentation().targetContext5353             .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager5454         clipboard.setPrimaryClip(ClipData.newPlainText("sentinel", "user-owned-clipboard"))55+        shell("logcat -c")5556         shell("am start -n com.peachpasswords.debug.test/com.peachpasswords.qa.QaSpoofedWebViewActivity")5657         Thread.sleep(2000)5758         focusField("Spoofed username field")5859         Thread.sleep(4000)60+        val logcat = shell("logcat -d -s PeachAutofill:D")61+        assertTrue(62+            "spoofed native form did not exercise Peach's unverified-web denial path:\n$logcat",63+            logcat.contains("refused unverified web origin"),64+        )5965         assertTrue(6066             "malicious autofill origin must not overwrite the user's clipboard",6167             clipboard.primaryClip?.getItemAt(0)?.text?.toString() == "user-owned-clipboard",

view file

app/src/androidTest/java/com/peachpasswords/qa/QaSpoofedWebViewActivity.java+38 −16
@@ -2,27 +2,49 @@22 33 import android.app.Activity;44 import android.os.Bundle;5+import android.text.InputType;56 import android.view.View;6-import android.webkit.WebView;7+import android.view.ViewStructure;8+import android.widget.EditText;9+import android.widget.LinearLayout;710 8-/** Companion app page that claims a victim domain without being a verified browser. */11+/** Companion app form that claims a victim web domain without being a verified browser. */912 public class QaSpoofedWebViewActivity extends Activity {1013     @Override1114     protected void onCreate(Bundle savedInstanceState) {1215         super.onCreate(savedInstanceState);13-        WebView webView = new WebView(this);14-        webView.setContentDescription("Spoofed victim-domain page");15-        webView.getSettings().setJavaScriptEnabled(true);16-        webView.loadDataWithBaseURL(17-                "https://github.com/login",18-                "<form><input aria-label='Spoofed username field' autocomplete='username' autofocus>"19-                        + "<input aria-label='Spoofed password field' type='password' autocomplete='current-password'>"20-                        + "<input aria-label='Spoofed 2FA field' autocomplete='one-time-code'></form>",21-                "text/html",22-                "UTF-8",23-                null24-        );25-        webView.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);26-        setContentView(webView);16+        SpoofedWebForm form = new SpoofedWebForm();17+        form.addView(field("Spoofed username field", View.AUTOFILL_HINT_USERNAME, InputType.TYPE_CLASS_TEXT));18+        form.addView(field(19+                "Spoofed password field",20+                View.AUTOFILL_HINT_PASSWORD,21+                InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD22+        ));23+        form.addView(field("Spoofed 2FA field", "smsOTPCode", InputType.TYPE_CLASS_NUMBER));24+        setContentView(form);25+    }26+27+    private EditText field(String description, String autofillHint, int inputType) {28+        EditText field = new EditText(this);29+        field.setContentDescription(description);30+        field.setHint(description);31+        field.setAutofillHints(autofillHint);32+        field.setInputType(inputType);33+        field.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);34+        return field;35+    }36+37+    private final class SpoofedWebForm extends LinearLayout {38+        SpoofedWebForm() {39+            super(QaSpoofedWebViewActivity.this);40+            setOrientation(VERTICAL);41+            setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_YES);42+        }43+44+        @Override45+        public void onProvideAutofillStructure(ViewStructure structure, int flags) {46+            super.onProvideAutofillStructure(structure, flags);47+            structure.setWebDomain("github.com");48+        }2749     }2850 }

view file

app/src/main/java/com/peachpasswords/autofill/AssistStructureParser.kt+2 −0
@@ -30,6 +30,8 @@3030     val packageName: String? = null,3131     val appTitle: String? = null,3232     val originDomains: List<String> = emptyList(),33+    /** False when an app claimed a web origin without a verified browser identity. */34+    val credentialReleaseAllowed: Boolean = true,3335     val cardNumberId: AutofillId? = null,3436     val cardExpMonthId: AutofillId? = null,3537     val cardExpYearId: AutofillId? = null,

view file

app/src/main/java/com/peachpasswords/autofill/AutofillEntryPickerActivity.kt+13 −1
@@ -144,6 +144,10 @@144144             cardNameId = intentAutofillId(AutofillIntents.EXTRA_CARD_NAME_ID),145145         )146146         val parsed = resolveAutofillOrigin(this, rawParsed)147+        if (!parsed.credentialReleaseAllowed) {148+            cancel()149+            return150+        }147151         val settings = vault.settings.value148152         val accent = settings.accentColor.let { ACCENT_COLORS[it] } ?: DEFAULT_ACCENT149153 @@ -333,6 +337,10 @@333337         parsed: ParsedStructure,334338         entry: VaultEntry,335339     ) {340+        if (!parsed.credentialReleaseAllowed) {341+            cancel()342+            return343+        }336344         val privatePreview = vault.settings.value.hideAutofillPreviews337345         val dataset = if (entry.isCard()) {338346             AutofillResponseBuilder.cardDatasetFor(this, parsed, entry, inlineSpec = null, privatePreview)@@ -430,7 +438,11 @@430438     entries: List<VaultEntry>,431439     parsed: ParsedStructure,432440     limit: Int,433-): List<VaultEntry> = AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)441+): List<VaultEntry> = if (parsed.credentialReleaseAllowed) {442+    AutofillResponseBuilder.matchEntries(entries, parsed).all.take(limit)443+} else {444+    emptyList()445+}434446 435447 /**436448  * Case-insensitive substring match across the four user-facing identity columns

view file

app/src/main/java/com/peachpasswords/autofill/AutofillOrigin.kt+27 −6
@@ -23,17 +23,38 @@2323  * produce no origin matches.2424  */2525 internal fun resolveAutofillOrigin(context: Context, parsed: ParsedStructure): ParsedStructure {26-    val webDomain = parsed.webDomain27-    if (webDomain != null) {28-        if (!PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName)) {29-            return parsed.copy(webDomain = null, originDomains = emptyList(), appTitle = null)30-        }31-        return parsed.copy(originDomains = listOfNotNull(extractDomain(webDomain)), appTitle = null)26+    if (parsed.webDomain != null) {27+        return applyWebOriginVerification(28+            parsed,29+            PasskeyCallerOrigin.isVerifiedBrowser(context, parsed.packageName),30+        )3231     }3332     return parsed.copy(3433         originDomains = appLinkDomains(context, parsed.packageName),3534         appTitle = appLabel(context, parsed.packageName),3635     )36+}37+38+/** Preserve an unverified web claim as a deny decision instead of reinterpreting it as native. */39+internal fun applyWebOriginVerification(40+    parsed: ParsedStructure,41+    verifiedBrowser: Boolean,42+): ParsedStructure {43+    val webDomain = parsed.webDomain ?: return parsed44+    return if (verifiedBrowser) {45+        parsed.copy(46+            originDomains = listOfNotNull(extractDomain(webDomain)),47+            appTitle = null,48+            credentialReleaseAllowed = true,49+        )50+    } else {51+        parsed.copy(52+            webDomain = null,53+            originDomains = emptyList(),54+            appTitle = null,55+            credentialReleaseAllowed = false,56+        )57+    }3758 }3859 3960 /** The display label of the calling app, for matching entries by app title. */

view file

app/src/main/java/com/peachpasswords/autofill/AutofillSave.kt+1 −0
@@ -19,6 +19,7 @@1919 }2020 2121 internal fun autofillSaveDraft(parsed: ParsedStructure): AutofillSaveDraft? {22+    if (!parsed.credentialReleaseAllowed) return null2223     val password = parsed.passwordValue?.trim().orEmpty()2324     if (password.isBlank()) return null2425     val origin = parsed.origin?.trim().orEmpty()

view file

app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+11 −1
@@ -53,7 +53,13 @@5353                     "specCount=${inlineSpecs(request).size}",5454             )5555         }56-        if (!parsed.hasCredentialField) { callback.onSuccess(null); return }56+        if (!parsed.hasCredentialField || !parsed.credentialReleaseAllowed) {57+            if (com.peachpasswords.BuildConfig.DEBUG && !parsed.credentialReleaseAllowed) {58+                android.util.Log.d("PeachAutofill", "refused unverified web origin")59+            }60+            callback.onSuccess(null)61+            return62+        }5763 5864         val inlineSpecs = inlineSpecs(request)5965         val privatePreview = vault.settings.value.hideAutofillPreviews@@ -198,6 +204,10 @@198204             return199205         }200206         val parsed = resolveAutofillOrigin(applicationContext, AssistStructureParser.parse(structure))207+        if (!parsed.credentialReleaseAllowed) {208+            callback.onSuccess()209+            return210+        }201211         val draft = autofillSaveDraft(parsed)202212         if (draft == null) {203213             callback.onSuccess()

view file

app/src/test/java/com/peachpasswords/autofill/AutofillOriginReleaseTest.kt+41 −0
@@ -0,0 +1,41 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertFalse4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+class AutofillOriginReleaseTest {9+10+    @Test11+    fun unverifiedWebClaimCannotReturnCredentialsOrSaveThem() {12+        val denied = applyWebOriginVerification(13+            ParsedStructure(14+                webDomain = "github.com",15+                packageName = "com.attacker.spoof",16+                passwordValue = "captured-password",17+            ),18+            verifiedBrowser = false,19+        )20+21+        assertFalse(denied.credentialReleaseAllowed)22+        assertNull(autofillSaveDraft(denied))23+    }24+25+    @Test26+    fun verifiedBrowserRetainsWebAutofill() {27+        val allowed = applyWebOriginVerification(28+            ParsedStructure(webDomain = "login.github.com", packageName = "com.android.chrome"),29+            verifiedBrowser = true,30+        )31+32+        assertTrue(allowed.credentialReleaseAllowed)33+    }34+35+    @Test36+    fun nativeAppRetainsAutofillWithoutWebClaim() {37+        val native = ParsedStructure(packageName = "com.example.app")38+39+        assertTrue(native.credentialReleaseAllowed)40+    }41+}

view file

app/src/test/java/com/peachpasswords/autofill/AutofillSaveTest.kt+9 −0
@@ -28,6 +28,15 @@2828     fun autofillSaveDraft_requiresOriginAndPassword() {2929         assertNull(autofillSaveDraft(ParsedStructure(webDomain = "example.com")))3030         assertNull(autofillSaveDraft(ParsedStructure(passwordValue = "pw")))31+        assertNull(32+            autofillSaveDraft(33+                ParsedStructure(34+                    webDomain = "example.com",35+                    passwordValue = "pw",36+                    credentialReleaseAllowed = false,37+                ),38+            ),39+        )3140     }3241 3342     @Test

view file