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
7 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 −8
manifest.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": {

view file

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",

view file

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",

view file

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 

view file

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+});

view file

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();

view file

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 

view file