1009fd2d3ebd
fix: Block unverified web autofill credential release (v2.14.73)
- time
- 2026-08-31 12:27 UTC · 1 month ago
- machine
- koh2
- parent
- 4b3185755696
↓ peach-android-1009fd2d.tar.gzatlas at this save
files changed10 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 −0app/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",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 }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,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 columnsapp/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. */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()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()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+}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