import { isVisible } from "./detect"; import { extractDomain } from "../shared/domain"; function getRegistrableDomain(hostname: string): string { return extractDomain(hostname) ?? hostname; } export function checkFormAction(form: HTMLFormElement | null, expectedDomain: string): boolean { if (!form?.action) return true; try { const actionUrl = new URL(form.action); // PB-26-008: a
element is a strong origin signal, so require // https and a host on the expected domain or a subdomain of it — not // merely any site sharing the registrable domain (eTLD+1). if (actionUrl.protocol !== "https:") return false; const host = actionUrl.hostname; if (getRegistrableDomain(host) !== expectedDomain) return false; if (host !== expectedDomain && !host.endsWith(`.${expectedDomain}`)) { return false; } } catch {} return true; } // Single decision for every fill path: PB-26-105 was three copies of this // condition with one of them missing, so a gate refusal in fillFields still // let the TOTP and confirm-password paths write into the hijacked form. export function fillAllowed( form: HTMLFormElement | null | undefined, expectedDomain?: string | null ): boolean { if (!form || !expectedDomain) return true; return checkFormAction(form, expectedDomain); } // Returns false when the form-action gate refused — callers must not write // anything else into the form after a refusal. export function fillFields( usernameEl: HTMLInputElement | null, passwordEl: HTMLInputElement | null, username: string, password: string, form?: HTMLFormElement | null, expectedDomain?: string, emailEl?: HTMLInputElement | null, email?: string ): boolean { if (!fillAllowed(form, expectedDomain)) { return false; } // Fill username regardless of visibility — on SPA step-by-step login flows // the username input may be visually hidden but still tracked by the form. if (usernameEl && username) { setNativeValue(usernameEl, username); } if (passwordEl && isVisible(passwordEl)) { setNativeValue(passwordEl, password); } if (emailEl && email && isVisible(emailEl)) { setNativeValue(emailEl, email); } return true; } // Distributes a TOTP code character-by-character into an ordered array of // single-character input boxes (the individual digit entry pattern used by // some 2FA flows). Triggers native input+change events on each box so // React/Vue controlled inputs update correctly. export function fillOTPDigitBoxes(boxes: HTMLInputElement[], code: string): void { const digits = code.replace(/\s/g, "").split(""); boxes.forEach((box, i) => { if (i < digits.length) setNativeValue(box, digits[i]!); }); } export function fillTOTPCode( totpEl: HTMLInputElement | null, code: string, form?: HTMLFormElement | null, expectedDomain?: string ): boolean { if (!totpEl || !code || !isVisible(totpEl)) return false; if (totpEl.value.trim() !== "") return false; if (!fillAllowed(form, expectedDomain)) return false; setNativeValue(totpEl, code); return true; } // React tracks input values via a synthetic event system that does not see // direct `.value =` assignment. We must call the native HTMLInputElement // value setter (pulled off the prototype) and then dispatch the real input // and change events so React re-reads the new value. Without this, the // field reads as empty to the form's controlled-input state. export function setNativeValue( el: HTMLInputElement | HTMLTextAreaElement, value: string ): void { const proto = el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; const nativeValueSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set; nativeValueSetter?.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } export interface CardFillFields { number: HTMLInputElement | null; expMonth: HTMLInputElement | null; expYear: HTMLInputElement | null; expiryCombo: HTMLInputElement | null; cvc: HTMLInputElement | null; name: HTMLInputElement | null; } export function fillCardForm(fields: CardFillFields, card: { cardNumber: string; expMonth: string; expYear: string; cvc: string; cardholderName: string }): void { // PB-26-007: same visibility gate as the password/email path in // fillFields — a crafted page must not feed card data into hidden fields. const raw = card.cardNumber.replace(/\s/g, ''); if (fields.number && isVisible(fields.number)) setNativeValue(fields.number, formatCardNumber(raw)); if (fields.expiryCombo && isVisible(fields.expiryCombo)) { setNativeValue(fields.expiryCombo, `${card.expMonth}/${card.expYear.slice(-2)}`); } else { if (fields.expMonth && isVisible(fields.expMonth)) setNativeValue(fields.expMonth, card.expMonth); if (fields.expYear && isVisible(fields.expYear)) setNativeValue(fields.expYear, card.expYear); } if (fields.cvc && isVisible(fields.cvc)) setNativeValue(fields.cvc, card.cvc); if (fields.name && isVisible(fields.name)) setNativeValue(fields.name, card.cardholderName); } export function formatCardNumber(raw: string): string { if (/^3[47]/.test(raw)) return raw.replace(/(\d{4})(\d{6})(\d{5})/, '$1 $2 $3'); return raw.replace(/(\d{4})(?=\d)/g, '$1 ').trim(); } export function detectCardNetwork(number: string): import('../shared/types').CardNetwork { const n = number.replace(/\s/g, ''); if (/^4/.test(n)) return 'visa'; if (/^5[1-5]|^2[2-7]/.test(n)) return 'mastercard'; if (/^3[47]/.test(n)) return 'amex'; if (/^6(?:011|5)/.test(n)) return 'discover'; return 'unknown'; } export function passesLuhn(number: string): boolean { const digits = number.replace(/\s/g, '').split('').map(Number); let sum = 0, isEven = false; for (let i = digits.length - 1; i >= 0; i--) { let d = digits[i]!; if (isEven) { d *= 2; if (d > 9) d -= 9; } sum += d; isEven = !isEven; } return sum % 10 === 0; } export function extractLast4(number: string): string { return number.replace(/\s/g, '').slice(-4); }