import { extractDomain } from "../shared/domain"; export interface DetectedField { type: "username" | "password" | "email"; el: HTMLInputElement; } // Conservative: "name" alone is too broad (matches "tag-name", "first-name", etc.). // No \b anchors — modern sites use camelCase like "userId", "loginEmail" where // the trigger token is contiguous with other word chars and would not match // at a word boundary. const USERNAME_PATTERNS = /(user(name|id|login|email)?|e?mail(box)?|login(email|username)?|account|identifier|signin|sign.?in|credential)/i; // Personal name fields (display name, first/last name) — not credential fields. // Applied to prevent the fallback from picking up name inputs on signup forms. const NAME_FIELD_PATTERNS = /\b(first[\s_-]?name|last[\s_-]?name|full[\s_-]?name|display[\s_-]?name|real[\s_-]?name|given[\s_-]?name|family[\s_-]?name|middle[\s_-]?name)\b|^(name|fname|lname|fullname|displayname|realname)$/i; // Input types that are never text-entry fields and must never get autofill // icons. submit/button/reset/image are interactive controls — putting the // icon over them blocks the click. The rest are structurally incapable of // holding a username/password. const SKIP_INPUT_TYPES = new Set([ "submit", "button", "reset", "image", "checkbox", "radio", "hidden", "file", "range", "color", "date", "time", "datetime-local", "month", "week", ]); // Some sites render submit buttons as with no type attribute (defaults to "text") // and a value like "Login" or "Submit". Catch those before injecting icons. function looksLikeSubmitButton(input: HTMLInputElement): boolean { if (input.type === "password") return false; const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim(); if (!val) return false; return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " ")); } function findLabelText(field: HTMLInputElement): string { const ariaLabel = field.getAttribute("aria-label") ?? ""; if (ariaLabel) return ariaLabel.toLowerCase(); const id = field.id; if (id) { // PCH-01-025: ids can contain CSS-special chars (quotes, brackets, leading // digits). Escape, and defensively swallow a malformed-selector throw so a // single weird id can't abort field detection for the whole page. try { const label = document.querySelector(`label[for="${CSS.escape(id)}"]`); if (label) return label.textContent?.toLowerCase() ?? ""; } catch { /* malformed id — skip label-by-id lookup, continue field detection */ } } const wrappingLabel = field.closest("label"); if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase(); let node: Element | null = field; for (let depth = 0; node && depth < 3; depth++) { const previous = node.previousElementSibling; const text = previous?.textContent?.trim().toLowerCase() ?? ""; if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) { return text; } node = node.parentElement; } return ""; } export function querySelectorAllDeep( root: Document | ShadowRoot | Element, selector: string ): T[] { const results: T[] = []; const walk = (node: Document | ShadowRoot | Element) => { if (node instanceof Element) { results.push(...(node.querySelectorAll(selector))); const shadowRoot = node.shadowRoot; if (shadowRoot) { shadowRoot.querySelectorAll("*").forEach(walk); } } }; root.querySelectorAll("*").forEach(walk); return results; } export function getDeepActiveInput(): HTMLInputElement | null { let current: Document | Element | null = document.activeElement; while (current) { if (current instanceof HTMLInputElement) return current; if (current instanceof Element && current.shadowRoot) { current = current.shadowRoot.activeElement; } else { break; } } return null; } // True for a single-character OTP digit box. Reads the maxlength ATTRIBUTE, not // the .maxLength property: for type="number"/"tel" inputs (the usual way to get // a numeric keypad for 2FA codes) the maxlength attribute is IGNORED — browsers // report .maxLength as a large default (524288) even when the author wrote // maxlength="1". Reading the attribute reliably captures the real intent, so a // numeric 2FA box group is recognised as split-OTP instead of each box being // injected with its own autofill icon. function isSingleDigitBox(el: HTMLInputElement): boolean { return el.getAttribute("maxlength") === "1"; } export function isTOTPField(input: HTMLInputElement): boolean { if (!isVisible(input)) return false; // Single-character inputs are individual OTP digit boxes — treat the form's // full-code input (if any) as the TOTP field, not each individual digit box. if (isSingleDigitBox(input)) return false; const autocomplete = input.autocomplete.toLowerCase(); const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase(); if ( autocomplete === "one-time-code" || /\b(otp|totp|2fa|mfa)\b/.test(fieldText) || /\b(verification|authenticator|security)\s+code\b/.test(fieldText) ) { return true; } const labelText = findLabelText(input); return ( /\b(otp|totp|2fa|mfa)\b/.test(labelText) || /\b(verification|authenticator|security)\s+code\b/.test(labelText) ); } // AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent. // ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.). const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i; const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i; // Email-first / identifier-first step: user enters email or username on this page; // the password prompt comes on the next step. Requires at least one auth-intent // signal (URL path, form action, or submit button text) to avoid false-positives // on newsletter and search forms that also have a single email field. export function isEmailFirstForm(fields: DetectedField[]): boolean { if (!fields.some(f => f.type === "username" || f.type === "email")) return false; if (fields.some(f => f.type === "password")) return false; // Strong URL signal: /login, /signin, /auth, /sso, /account, etc. if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true; // Check submit buttons for auth intent. const buttons = Array.from(document.querySelectorAll( 'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])' )); const hasAuthButton = buttons.some(b => { const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? ""; return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t); }); if (!hasAuthButton) return false; // Reject if the same form has name/phone/address fields — that's registration, not login. const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el; const form = identifierEl.closest("form") ?? document.body; const hasRegistrationFields = Array.from(form.querySelectorAll("input")).some(el => { if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false; if (!isVisible(el)) return false; return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i .test(el.name + el.id + el.placeholder); }); return !hasRegistrationFields; } // Password-step: the email/username was entered on a previous page; this page // shows only a single password field. Also catches sites that render username // and password on separate screens without autocomplete hints. export function isPasswordStepForm(fields: DetectedField[]): boolean { const passwords = fields.filter(f => f.type === "password"); return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email"); } // Single entry point used by every gate in index.ts. Covers all four patterns: // traditional login, signup, email-first step, and password-only step. export function isAnyCredentialForm(fields: DetectedField[]): boolean { return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields); } export function isLoginForm(fields: DetectedField[]): boolean { const passwords = fields.filter((f) => f.type === "password"); // Two or more password fields means signup/change-password, not login if (passwords.length >= 2) return false; const hasPassword = passwords.length > 0; const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email"); if (hasPassword && hasUsernameOrEmail) return true; if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") { return true; } return false; } export function isSignupForm(fields: DetectedField[]): boolean { const passwords = fields.filter((f) => f.type === "password"); if (passwords.length >= 2) return true; if (passwords.some((f) => f.el.autocomplete === "new-password")) return true; if ( passwords.some((f) => { const id = (f.el.id + f.el.name).toLowerCase(); return /new|confirm/i.test(id); }) ) { return true; } return false; } export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] { const inputs = Array.from(querySelectorAllDeep(root, "input")); const fields: DetectedField[] = []; for (const el of inputs) { if (!isVisible(el)) continue; const type = el.type.toLowerCase(); // Never treat buttons, checkboxes, hidden fields, etc. as autofill targets if (SKIP_INPUT_TYPES.has(type)) continue; // Single-character inputs are individual OTP digit boxes — never credential fields if (isSingleDigitBox(el)) continue; // Catch with no type attribute whose value text identifies it as a submit button if (looksLikeSubmitButton(el)) continue; const ac = el.autocomplete; const name = (el.name + el.id + el.placeholder).toLowerCase(); if (ac === "current-password" || ac === "new-password" || type === "password") { fields.push({ type: "password", el }); continue; } if (ac === "username") { fields.push({ type: "username", el }); continue; } // Explicit email autocomplete or type — classified as "email" so interaction.ts // can track it separately from the username field on signup forms. if (ac === "email" || type === "email") { fields.push({ type: "email", el }); continue; } if (USERNAME_PATTERNS.test(name)) { fields.push({ type: "username", el }); continue; } const labelText = findLabelText(el); if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) { fields.push({ type: "username", el }); } } // Fallback: if we have a password field but no username/email, look for // the first visible text input that appears before the password in DOM order. // This catches sites with unconventional field names (e.g. name="identifier"). const hasUsername = fields.some((f) => f.type === "username" || f.type === "email"); const pwField = fields.find((f) => f.type === "password"); if (!hasUsername && pwField) { const allInputs = Array.from(inputs); const pwIndex = allInputs.indexOf(pwField.el); for (let i = 0; i < pwIndex; i++) { const candidate = allInputs[i]; const candidateType = candidate.type.toLowerCase(); const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase(); const candidateLabel = findLabelText(candidate); if ( (candidateType === "text" || candidateType === "email" || candidateType === "") && isVisible(candidate) && !SKIP_INPUT_TYPES.has(candidateType) && !fields.some((f) => f.el === candidate) && !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel) ) { fields.push({ type: "username", el: candidate }); break; // only take the first one } } } return fields; } // Returns the ordered list of single-character inputs that form a multi-digit // OTP entry group, or null if `el` is not part of one. Anchors the search on // the nearest
; falls back to document.body for formless SPA pages. // Groups of fewer than 4 or more than 10 boxes are ignored — those ranges // are outside every practical OTP code length. export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null { if (!isSingleDigitBox(el)) return null; const t = el.type.toLowerCase(); if (!["text", "number", "tel", ""].includes(t)) return null; if (!isVisible(el)) return null; const searchRoot: Element = el.closest("form") ?? document.body; const digitBoxes = Array.from(searchRoot.querySelectorAll("input")).filter(input => { if (!isSingleDigitBox(input)) return false; if (!isVisible(input)) return false; const it = input.type.toLowerCase(); return ["text", "number", "tel", ""].includes(it); }); if (digitBoxes.length < 4 || digitBoxes.length > 10) return null; if (!digitBoxes.includes(el)) return null; return digitBoxes; } // Addendum 1: true when `field` belongs to a split single-character OTP group — // 3+ maxlength=1 inputs in the same form that each carry a numeric signal // (inputmode=numeric, type=tel, pattern=[0-9]*/\d*, or autocomplete=one-time-code). // Distinct from findOTPDigitBoxGroup, which gates on a 4–10 box count and accepts // any text/number/tel box: this drops the floor to 3 but adds the numeric guard, // so a 3-letter "initials" form is never mistaken for OTP entry. // // No icon-suppression guard is wired at the injection sites: every injectFieldIcon // path already filters maxLength===1 fields upstream (detectLoginFields skips them, // isTOTPField rejects them), so a split-OTP box never reaches icon injection. This // export is the numeric-guarded classifier the T-DT18–21 spec pins and a shared // seam for any future gate that needs it. export function isSplitOtpGroup(field: HTMLInputElement): boolean { if (!isSingleDigitBox(field)) return false; const form = field.form; if (!form) return false; const siblings = Array.from(form.querySelectorAll("input")).filter( (el) => isSingleDigitBox(el) && (el.inputMode === "numeric" || el.type === "tel" || el.pattern === "[0-9]*" || el.pattern === "\\d*" || el.autocomplete === "one-time-code") ); return siblings.length >= 3 && siblings.includes(field); } // Returns the ordered OTP-signaling inputs sharing `field`'s form when there are // 4–10 of them — the split-digit-box pattern used by sites whose individual // boxes lack maxlength="1" (so isSingleDigitBox / findOTPDigitBoxGroup miss them // and each box would otherwise get its own injected icon). isTOTPField already // rejects maxlength=1 boxes, so this never overlaps with findOTPDigitBoxGroup; // a lone one-time-code field has only one peer and returns null. export function findOtpSignalingGroup(field: HTMLInputElement): HTMLInputElement[] | null { if (!isVisible(field)) return null; if (!isTOTPField(field)) return null; const root: Element = field.form ?? document.body; const peers = Array.from(root.querySelectorAll("input")).filter(isTOTPField); if (peers.length < 4 || peers.length > 10) return null; return peers.includes(field) ? peers : null; } export function isVisible(el: HTMLElement): boolean { const s = getComputedStyle(el); const r = el.getBoundingClientRect(); return ( s.display !== "none" && s.visibility !== "hidden" && parseFloat(s.opacity) > 0 && r.width > 0 && r.height > 0 ); } export function getPageDomain(): string { return extractDomain(location.hostname) ?? location.hostname; } export interface CardFormFields { number: HTMLInputElement | null; expMonth: HTMLInputElement | null; expYear: HTMLInputElement | null; expiryCombo: HTMLInputElement | null; cvc: HTMLInputElement | null; name: HTMLInputElement | null; } export function detectCardForm(root: Document | ShadowRoot = document): CardFormFields | null { const numberByAC = root.querySelector('input[autocomplete="cc-number"]'); const expByAC = root.querySelector('input[autocomplete="cc-exp"]'); const expMonthByAC = root.querySelector('input[autocomplete="cc-exp-month"]'); const expYearByAC = root.querySelector('input[autocomplete="cc-exp-year"]'); const cvcByAC = root.querySelector( 'input[autocomplete="cc-csc"], input[autocomplete="cc-cvc"]' ); const nameByAC = root.querySelector('input[autocomplete="cc-name"]'); if (numberByAC) { return { number: numberByAC, expMonth: expMonthByAC, expYear: expYearByAC, expiryCombo: expByAC, cvc: cvcByAC, name: nameByAC }; } const allInputs = Array.from(root.querySelectorAll('input')); const numberField = allInputs.find(i => /card.?num|cc.?num|cardnum|pan\b/i.test(i.name + i.id + i.placeholder) ) ?? null; const cvcField = allInputs.find(i => /cvc|cvv|csc|security.?code/i.test(i.name + i.id + i.placeholder) ) ?? null; const expField = allInputs.find(i => /exp|expiry|expiration/i.test(i.name + i.id + i.placeholder) ) ?? null; if (!numberField) return null; return { number: numberField, expMonth: null, expYear: null, expiryCombo: expField, cvc: cvcField, name: null }; } const PAYMENT_PROCESSOR_IFRAMES = ['stripe.com', 'js.stripe.com', 'braintreegateway.com', 'paypal.com', 'adyen.com']; export function hasPaymentProcessorIframe(): boolean { const iframes = Array.from(document.querySelectorAll('iframe')); return iframes.some(f => PAYMENT_PROCESSOR_IFRAMES.some(host => f.src.includes(host))); }