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
↓ peach-browser-4488f345.tar.gzatlas at this save
files changed2 files
src/content/detect.test.ts+18 −0src/content/detect.ts+17 −6src/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", () => {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]*" ||