4021a2198f72
fix: Fill same-form 2FA with login credentials (v3.6.48)
- time
- 2026-05-19 23:39 UTC · 5 months ago
- machine
- koh2
- parent
- b68ab276fa94
↓ peach-browser-4021a219.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/content/content.adversarial.test.ts+16 −0src/content/detect.test.ts+9 −0src/content/detect.ts+12 −1src/content/fill.ts+10 −0src/content/index.ts+20 −19manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.47",5+ "version": "3.6.48",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.47",4+ "version": "3.6.48",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22 "name": "peach",3- "version": "3.6.47",3+ "version": "3.6.48",44 "lockfileVersion": 3,55 "requires": true,66 "packages": {77 "": {88 "name": "peach",9- "version": "3.6.47",9+ "version": "3.6.48",1010 "dependencies": {1111 "@bokuweb/zstd-wasm": "^0.0.27",1212 "@stablelib/blake2s": "^2.0.1",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.47",3+ "version": "3.6.48",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/content/content.adversarial.test.ts+16 −0
@@ -27,6 +27,7 @@2727 detectCardNetwork,2828 extractLast4,2929 fillFields,30+ fillTOTPCode,3031 formatCardNumber,3132 passesLuhn,3233 setNativeValue,@@ -373,6 +374,21 @@373374 fillFields(username, password, "", "pw");374375 expect(username.value).toBe("preexisting");375376 expect(password.value).toBe("pw");377+ });378+379+ it("fillTOTPCode fills an empty visible 2FA field", () => {380+ const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });381+ const filled = fillTOTPCode(totp, "123456");382+ expect(filled).toBe(true);383+ expect(totp.value).toBe("123456");384+ });385+386+ it("fillTOTPCode does not overwrite an existing 2FA value", () => {387+ const totp = visibleInput({ type: "text", autocomplete: "one-time-code" });388+ totp.value = "654321";389+ const filled = fillTOTPCode(totp, "123456");390+ expect(filled).toBe(false);391+ expect(totp.value).toBe("654321");376392 });377393 });378394 src/content/detect.test.ts+9 −0
@@ -108,6 +108,15 @@108108 expect(isTOTPField(verification)).toBe(true);109109 expect(isTOTPField(zipCode)).toBe(false);110110 });111+112+ it("isTOTPField detects an adjacent 2FA label", () => {113+ const label = document.createElement("label");114+ label.textContent = "2FA Code";115+ document.body.appendChild(label);116+ const input = visibleInput({ placeholder: "If enabled" });117+118+ expect(isTOTPField(input)).toBe(true);119+ });111120 });112121 113122 describe("card form detection", () => {src/content/detect.ts+12 −1
@@ -40,7 +40,18 @@4040 if (label) return label.textContent?.toLowerCase() ?? "";4141 }4242 const wrappingLabel = field.closest("label");43- return wrappingLabel?.textContent?.toLowerCase() ?? "";43+ if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();44+45+ let node: Element | null = field;46+ for (let depth = 0; node && depth < 3; depth++) {47+ const previous = node.previousElementSibling;48+ const text = previous?.textContent?.trim().toLowerCase() ?? "";49+ if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {50+ return text;51+ }52+ node = node.parentElement;53+ }54+ return "";4455 }4556 4657 export function querySelectorAllDeep<T extends Element>(src/content/fill.ts+10 −0
@@ -38,6 +38,16 @@3838 }3939 }4040 41+export function fillTOTPCode(42+ totpEl: HTMLInputElement | null,43+ code: string44+): boolean {45+ if (!totpEl || !code || !isVisible(totpEl)) return false;46+ if (totpEl.value.trim() !== "") return false;47+ setNativeValue(totpEl, code);48+ return true;49+}50+4151 // React tracks input values via a synthetic event system that does not see4252 // direct `.value =` assignment. We must call the native HTMLInputElement4353 // value setter (pulled off the prototype) and then dispatch the real inputsrc/content/index.ts+20 −19
@@ -22,7 +22,7 @@2222 } from "./inject";2323 import { detectBrowser } from "../shared/browser";2424 import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";25-import { fillCardForm, fillFields, setNativeValue } from "./fill";25+import { fillCardForm, fillFields, fillTOTPCode, setNativeValue } from "./fill";2626 import { setupSaveDetection } from "./save";2727 import { contentMLDetector } from "./content-ml-detector";2828 import { detectPasswordPolicy } from "./policy";@@ -285,21 +285,17 @@285285 });286286 }287287 288-function findTOTPField(): HTMLInputElement | null {289- const inputs = document.querySelectorAll<HTMLInputElement>("input");288+function findTOTPField(root: Document | Element = document): HTMLInputElement | null {289+ const inputs = root.querySelectorAll<HTMLInputElement>("input");290290 for (const input of inputs) {291291 if (isTOTPField(input)) return input;292292 }293293 return null;294294 }295295 296-async function autofillTOTP(totpField: HTMLInputElement, totpSecret: string): Promise<void> {297- await new Promise((r) => setTimeout(r, 400));298- if (totpField.value === "" || totpField.value.trim() === "") {299- const { code } = generateTOTP(totpSecret);300- setNativeValue(totpField, code);301- showToast("TOTP autofilled");302- }296+function findSameLoginTOTPField(passwordEl: HTMLInputElement): HTMLInputElement | null {297+ const form = passwordEl.closest("form");298+ return findTOTPField(form ?? document);303299 }304300 305301 @@ -308,11 +304,12 @@308304 usernameEl: HTMLInputElement | null,309305 passwordEl: HTMLInputElement,310306 entry: VaultEntry311-): Promise<void> {312- if (!entry.username && !entry.password) return;307+): Promise<HTMLInputElement | null> {308+ if (!entry.username && !entry.password) return null;313309 const pageDomain = getPageDomain();314310 const form = passwordEl.closest("form") as HTMLFormElement | null;315311 fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);312+ let filledTOTPField: HTMLInputElement | null = null;316313 317314 if (entry.canary) {318315 chrome.runtime.sendMessage({@@ -340,6 +337,10 @@340337 341338 if (entry.totp) {342339 const { code } = generateTOTP(entry.totp);340+ const totpField = findSameLoginTOTPField(passwordEl);341+ if (fillTOTPCode(totpField, code)) {342+ filledTOTPField = totpField;343+ }343344 let alreadyWritten = false;344345 try {345346 await navigator.clipboard.writeText(code);@@ -351,16 +352,16 @@351352 type: "COPY",352353 payload: { text: code, label: "TOTP", alreadyWritten },353354 });354- if (alreadyWritten || (response as { ok?: boolean }).ok) {355+ if (filledTOTPField) {356+ showToast("2FA code filled");357+ } else if (alreadyWritten || (response as { ok?: boolean }).ok) {355358 showToast("TOTP copied · " + code);356359 } else {357360 showToast("Could not copy TOTP — clipboard blocked");358361 }359- const totpField = findTOTPField();360- if (totpField) {361- autofillTOTP(totpField, entry.totp);362- }363362 }363+364+ return filledTOTPField;364365 }365366 366367 async function fetchAutofillEntries(@@ -458,8 +459,8 @@458459 });459460 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;460461 if (entry) {461- await handleAutofill(anchorField, usernameEl, passwordEl, entry);462- recordFillSuccess(anchorField, usernameEl, passwordEl);462+ const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry);463+ recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);463464 // Offer to remember the current site for this entry only if none of464465 // its known domains (primary + additional) match — otherwise the465466 // user already mapped it and the prompt would be noise.