da74e3f68636
collapse OTP-signaling split-digit boxes (no maxlength) to a single icon on box[0] instead of one per box
- time
- 2026-08-13 20:00 UTC · 2 months ago
- machine
- koh2
- agent
- glm-5.2
- session
- fix 2fa icon spam in split-otp boxes
- parent
- 2c20a75301a0
↓ peach-browser-da74e3f6.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/content.detect.test.ts+63 −0src/content/content.totp-group-icon.test.ts+101 −0src/content/detect.ts+15 −0src/content/index.ts+29 −8manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.9.6",4+ "version": "4.9.7",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.9.6",5+ "version": "4.9.7",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.9.6",3+ "version": "4.9.7",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/content/content.detect.test.ts+63 −0
@@ -15,6 +15,7 @@1515 detectCardForm,1616 detectLoginFields,1717 findOTPDigitBoxGroup,18+ findOtpSignalingGroup,1819 isAnyCredentialForm,1920 isEmailFirstForm,2021 isLoginForm,@@ -344,6 +345,68 @@344345 makeInput({ type: "text", maxlength: "1", inputmode: "numeric" }, f),345346 ];346347 expect(findOTPDigitBoxGroup(boxes[0]!)).toBeNull();348+ });349+});350+351+// ── Addendum 2 — OTP-signaling groups without maxlength="1" ──────────────────352+// Some sites render split-digit 2FA boxes as plain inputs that carry OTP353+// signaling (autocomplete="one-time-code", name="otp", …) but never set354+// maxlength="1". isSingleDigitBox / findOTPDigitBoxGroup miss them, so without355+// findOtpSignalingGroup each box would get its own injected icon (the "peach356+// icon spam in every 2FA box" bug). The group is detected by OTP signaling +357+// count, so a phone-form's numeric boxes (no OTP text) are never matched.358+describe("T-DT22–T-DT24: findOtpSignalingGroup (split-OTP boxes lacking maxlength)", () => {359+ // T-DT22: 6 one-time-code boxes without maxlength → a single group of 6,360+ // ordered, anchored on box[0]. Each box still classifies as a TOTP field361+ // (the property scanTOTPFields keys on), so without grouping every one of362+ // them would be injected.363+ it("T-DT22: 6 one-time-code boxes (no maxlength) → group of 6, anchor is box[0]", () => {364+ const f = form();365+ const boxes: HTMLInputElement[] = [];366+ for (let i = 0; i < 6; i++) {367+ boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));368+ }369+ expect(isTOTPField(boxes[3]!)).toBe(true);370+ const group = findOtpSignalingGroup(boxes[3]!);371+ expect(group).not.toBeNull();372+ expect(group!.length).toBe(6);373+ expect(group![0]).toBe(boxes[0]);374+ expect(group![5]).toBe(boxes[5]);375+ });376+377+ // T-DT23: a lone one-time-code field is NOT a group — it gets its own icon378+ // via the normal lone-TOTP path. findOtpSignalingGroup returns null so the379+ // caller injects on the field itself.380+ it("T-DT23: a lone one-time-code field → null (not a group)", () => {381+ const f = form();382+ const lone = makeInput({ type: "text", autocomplete: "one-time-code", name: "code" }, f);383+ expect(findOtpSignalingGroup(lone)).toBeNull();384+ });385+386+ // T-DT24: only 3 OTP-signaling boxes is below the 4-box floor → null. Avoids387+ // grouping incidental otp-named fields that happen to coexist on a form.388+ it("T-DT24: only 3 one-time-code boxes → null (below 4-box floor)", () => {389+ const f = form();390+ const boxes: HTMLInputElement[] = [];391+ for (let i = 0; i < 3; i++) {392+ boxes.push(makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f));393+ }394+ expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();395+ });396+397+ // T-DT25: a maxlength="1" box is rejected by isTOTPField, so it never enters398+ // an OTP-signaling group — it is handled exclusively by findOTPDigitBoxGroup.399+ // Confirms the two finders don't overlap and maxlength boxes keep their400+ // existing (zero-icon + focus-open) behaviour.401+ it("T-DT25: a maxlength=1 box is not part of an OTP-signaling group", () => {402+ const f = form();403+ const boxes: HTMLInputElement[] = [];404+ for (let i = 0; i < 6; i++) {405+ boxes.push(makeInput({ type: "text", maxlength: "1", inputmode: "numeric", autocomplete: "one-time-code" }, f));406+ }407+ expect(isTOTPField(boxes[0]!)).toBe(false);408+ expect(findOtpSignalingGroup(boxes[0]!)).toBeNull();409+ expect(findOTPDigitBoxGroup(boxes[0]!)).not.toBeNull();347410 });348411 });349412 src/content/content.totp-group-icon.test.ts+101 −0
@@ -0,0 +1,101 @@1+// @vitest-environment jsdom2+//3+// Regression: split-OTP digit boxes that lack maxlength="1" but carry OTP4+// signaling (autocomplete="one-time-code", name="otp", …) used to each get5+// their own injected peach icon — the "icon spammed into every 2FA box" bug.6+// scanTOTPFields must collapse such a group to a single icon on the first box7+// and pass the full group so the selector fills every box at once.8+9+import { vi } from "vitest";10+11+const { injectIcon } = vi.hoisted(() => ({12+ injectIcon: vi.fn<(field: HTMLInputElement) => () => void>(() => vi.fn()),13+}));14+15+vi.mock("./inject", () => ({16+ injectFieldIcon: injectIcon,17+ showAutofillSelector: vi.fn(() => vi.fn()),18+ showTOTPSelector: vi.fn(() => vi.fn()),19+ showCardSelector: vi.fn(() => vi.fn()),20+ showRememberSiteToast: vi.fn(),21+ showPasskeyAvailable: vi.fn(),22+ showPasskeyProGate: vi.fn(),23+ showPasskeyRegistrationOffer: vi.fn(),24+}));25+vi.mock("./content-ml-detector", () => ({26+ contentMLDetector: { predictBatch: vi.fn(() => Promise.resolve([])) },27+}));28+29+import { afterEach, beforeEach, describe, expect, it } from "vitest";30+import "./_setup";31+import { resetChromeMock, makeInput } from "./_setup";32+import { scanTOTPFields } from "./index";33+34+beforeEach(() => {35+ document.body.innerHTML = "";36+ resetChromeMock();37+ injectIcon.mockClear();38+});39+40+afterEach(async () => {41+ // Flush the module-load setTimeout(scanTOTPFields, 0/500/1500) calls so they42+ // run against an empty DOM (cleared above) and never bleed into the next test.43+ await new Promise((r) => setTimeout(r, 0));44+});45+46+describe("split-OTP group icon injection (no maxlength)", () => {47+ // The bug: six one-time-code boxes without maxlength="1" each classify as a48+ // TOTP field, so the naive scan injects six icons. The fix resolves the whole49+ // group to a single anchor (box[0]) and relies on the processedTOTPInputs50+ // WeakMap to inject exactly once.51+ it("six one-time-code boxes (no maxlength) → one icon on box[0]", () => {52+ const f = document.createElement("form");53+ document.body.appendChild(f);54+ const boxes: HTMLInputElement[] = [];55+ for (let i = 0; i < 6; i++) {56+ boxes.push(57+ makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f)58+ );59+ }60+61+ scanTOTPFields();62+63+ expect(injectIcon).toHaveBeenCalledTimes(1);64+ expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]);65+ });66+67+ // A second scan must not stack another icon — the WeakMap dedup guards68+ // against duplicate icons on re-scan (the MutationObserver path).69+ it("rescan is idempotent — still one icon", () => {70+ const f = document.createElement("form");71+ document.body.appendChild(f);72+ const boxes: HTMLInputElement[] = [];73+ for (let i = 0; i < 6; i++) {74+ boxes.push(75+ makeInput({ type: "text", autocomplete: "one-time-code", name: "otp" }, f)76+ );77+ }78+79+ scanTOTPFields();80+ scanTOTPFields();81+82+ expect(injectIcon).toHaveBeenCalledTimes(1);83+ expect(injectIcon.mock.calls[0]![0]).toBe(boxes[0]);84+ });85+86+ // A lone one-time-code field (no sibling boxes) is not a group — it keeps its87+ // own icon on itself, as before.88+ it("a lone one-time-code field → one icon on itself", () => {89+ const f = document.createElement("form");90+ document.body.appendChild(f);91+ const lone = makeInput(92+ { type: "text", autocomplete: "one-time-code", name: "code" },93+ f94+ );95+96+ scanTOTPFields();97+98+ expect(injectIcon).toHaveBeenCalledTimes(1);99+ expect(injectIcon.mock.calls[0]![0]).toBe(lone);100+ });101+});src/content/detect.ts+15 −0
@@ -334,6 +334,21 @@334334 return siblings.length >= 3 && siblings.includes(field);335335 }336336 337+// Returns the ordered OTP-signaling inputs sharing `field`'s form when there are338+// 4–10 of them — the split-digit-box pattern used by sites whose individual339+// boxes lack maxlength="1" (so isSingleDigitBox / findOTPDigitBoxGroup miss them340+// and each box would otherwise get its own injected icon). isTOTPField already341+// rejects maxlength=1 boxes, so this never overlaps with findOTPDigitBoxGroup;342+// a lone one-time-code field has only one peer and returns null.343+export function findOtpSignalingGroup(field: HTMLInputElement): HTMLInputElement[] | null {344+ if (!isVisible(field)) return null;345+ if (!isTOTPField(field)) return null;346+ const root: Element = field.form ?? document.body;347+ const peers = Array.from(root.querySelectorAll<HTMLInputElement>("input")).filter(isTOTPField);348+ if (peers.length < 4 || peers.length > 10) return null;349+ return peers.includes(field) ? peers : null;350+}351+337352 export function isVisible(el: HTMLElement): boolean {338353 const s = getComputedStyle(el);339354 const r = el.getBoundingClientRect();src/content/index.ts+29 −8
@@ -2,6 +2,7 @@22 detectCardForm,33 detectLoginFields,44 findOTPDigitBoxGroup,5+ findOtpSignalingGroup,56 getDeepActiveInput,67 getPageDomain,78 isAnyCredentialForm,@@ -628,9 +629,12 @@628629 pendingInteractions.add(field);629630 630631 try {631- // Multi-digit OTP entry: individual maxLength=1 boxes get no icon (too small)632- // but the TOTP selector opens on focus and fills every box at once.633- const otpGroup = findOTPDigitBoxGroup(field);632+ // Multi-digit OTP entry: a group of digit boxes opens one selector anchored633+ // on the first box and fills every box at once — never one icon per box.634+ // findOTPDigitBoxGroup covers maxlength=1 boxes; findOtpSignalingGroup635+ // covers boxes that carry OTP signaling (autocomplete=one-time-code,636+ // name/id/label matches otp/totp/2fa) but lack maxlength="1".637+ const otpGroup = findOTPDigitBoxGroup(field) ?? findOtpSignalingGroup(field);634638 if (otpGroup) {635639 await openTOTPSelector(otpGroup[0]!, otpGroup);636640 return;@@ -842,7 +846,10 @@842846 return isAnyCredentialForm(fields);843847 }844848 845-function injectTOTPFieldIcon(field: HTMLInputElement): void {849+function injectTOTPFieldIcon(850+ field: HTMLInputElement,851+ otpDigitBoxes?: HTMLInputElement[]852+): void {846853 if (processedTOTPInputs.has(field)) return;847854 const cleanup = injectFieldIcon(field, () => {848855 currentDismiss?.();@@ -853,17 +860,30 @@853860 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }854861 clearFillStateForExplicitSummon(field);855862 field.focus({ preventScroll: true });856- void openTOTPSelector(field);863+ void openTOTPSelector(field, otpDigitBoxes);857864 });858865 processedTOTPInputs.set(field, cleanup);859866 }860867 861-function scanTOTPFields(): void {868+// A split-OTP group (4+ OTP-signaling boxes lacking maxlength="1") shows one869+// icon on the first box, not one per box. Returns the anchor to inject on870+// (group[0]) and its full group, or just the field itself when it's a lone871+// TOTP input.872+function resolveTOTPIconTarget(873+ field: HTMLInputElement874+): { anchor: HTMLInputElement; group?: HTMLInputElement[] } {875+ const group = findOtpSignalingGroup(field);876+ if (group) return { anchor: group[0]!, group };877+ return { anchor: field };878+}879+880+export /* @testable */ function scanTOTPFields(): void {862881 if (hasLoginOrSignupForm()) return;863882 const inputs = document.querySelectorAll<HTMLInputElement>("input");864883 for (const input of inputs) {865884 if (!isTOTPField(input)) continue;866- injectTOTPFieldIcon(input);885+ const { anchor, group } = resolveTOTPIconTarget(input);886+ injectTOTPFieldIcon(anchor, group);867887 }868888 }869889 @@ -885,7 +905,8 @@885905 if (!(target instanceof HTMLInputElement)) return;886906 if (hasLoginOrSignupForm()) return;887907 if (!isTOTPField(target)) return;888- injectTOTPFieldIcon(target);908+ const { anchor, group } = resolveTOTPIconTarget(target);909+ injectTOTPFieldIcon(anchor, group);889910 }, true);890911 }891912