4488f3459d17

detect single-digit OTP boxes by maxlength attribute so type=number 2FA boxes no longer each get a peach icon

time
2026-08-13 19:22 UTC · 2 months ago
machine
koh2
agent
deepseek-v4-flash
session
otp number box icon
parent
8b8b37f3866f
2 files src/content/detect.test.ts+18 −0src/content/detect.ts+17 −6
src/content/detect.test.ts+18 −0
@@ -118,6 +118,24 @@118118     expect(isTOTPField(input)).toBe(true);119119   });120120 121+  // Regression: numeric 2FA boxes are usually type="number", where the maxlength122+  // ATTRIBUTE is set to 1 but the .maxLength PROPERTY ignores it and reports a123+  // large default. Detection must key on the attribute so each digit box is124+  // recognised as a split-OTP box, not a full TOTP field getting its own icon.125+  it("isTOTPField ignores type=number split-OTP digit boxes", () => {126+    for (let i = 0; i < 6; i++) {127+      const box = visibleInput({ type: "number", maxlength: "1", name: "otp" });128+      expect(isTOTPField(box)).toBe(false);129+    }130+  });131+132+  it("detectLoginFields skips type=number split-OTP digit boxes", () => {133+    for (let i = 0; i < 6; i++) {134+      visibleInput({ type: "number", maxlength: "1", name: "otp" });135+    }136+    expect(detectLoginFields(document)).toEqual([]);137+  });138+121139   // PCH-01-025: ids may legally contain CSS-special chars; field detection122140   // must not throw a SyntaxError out of the label[for=...] lookup.123141   it("field_detection_does_not_throw_on_id_with_css_special_chars", () => {

view file

src/content/detect.ts+17 −6
@@ -96,11 +96,22 @@9696   return null;9797 }9898 99+// True for a single-character OTP digit box. Reads the maxlength ATTRIBUTE, not100+// the .maxLength property: for type="number"/"tel" inputs (the usual way to get101+// a numeric keypad for 2FA codes) the maxlength attribute is IGNORED — browsers102+// report .maxLength as a large default (524288) even when the author wrote103+// maxlength="1". Reading the attribute reliably captures the real intent, so a104+// numeric 2FA box group is recognised as split-OTP instead of each box being105+// injected with its own autofill icon.106+function isSingleDigitBox(el: HTMLInputElement): boolean {107+  return el.getAttribute("maxlength") === "1";108+}109+99110 export function isTOTPField(input: HTMLInputElement): boolean {100111   if (!isVisible(input)) return false;101112   // Single-character inputs are individual OTP digit boxes — treat the form's102113   // full-code input (if any) as the TOTP field, not each individual digit box.103-  if (input.maxLength === 1) return false;114+  if (isSingleDigitBox(input)) return false;104115   const autocomplete = input.autocomplete.toLowerCase();105116   const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();106117   if (@@ -208,7 +219,7 @@208219     // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets209220     if (SKIP_INPUT_TYPES.has(type)) continue;210221     // Single-character inputs are individual OTP digit boxes — never credential fields211-    if (el.maxLength === 1) continue;222+    if (isSingleDigitBox(el)) continue;212223     // Catch <input> with no type attribute whose value text identifies it as a submit button213224     if (looksLikeSubmitButton(el)) continue;214225     const ac = el.autocomplete;@@ -277,14 +288,14 @@277288 // Groups of fewer than 4 or more than 10 boxes are ignored — those ranges278289 // are outside every practical OTP code length.279290 export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {280-  if (el.maxLength !== 1) return null;291+  if (!isSingleDigitBox(el)) return null;281292   const t = el.type.toLowerCase();282293   if (!["text", "number", "tel", ""].includes(t)) return null;283294   if (!isVisible(el)) return null;284295 285296   const searchRoot: Element = el.closest("form") ?? document.body;286297   const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {287-    if (input.maxLength !== 1) return false;298+    if (!isSingleDigitBox(input)) return false;288299     if (!isVisible(input)) return false;289300     const it = input.type.toLowerCase();290301     return ["text", "number", "tel", ""].includes(it);@@ -308,12 +319,12 @@308319 // export is the numeric-guarded classifier the T-DT18–21 spec pins and a shared309320 // seam for any future gate that needs it.310321 export function isSplitOtpGroup(field: HTMLInputElement): boolean {311-  if (field.maxLength !== 1) return false;322+  if (!isSingleDigitBox(field)) return false;312323   const form = field.form;313324   if (!form) return false;314325   const siblings = Array.from(form.querySelectorAll<HTMLInputElement>("input")).filter(315326     (el) =>316-      el.maxLength === 1 &&327+      isSingleDigitBox(el) &&317328       (el.inputMode === "numeric" ||318329         el.type === "tel" ||319330         el.pattern === "[0-9]*" ||

view file