fill.ts

6.0 KB · raw

1import { isVisible } from "./detect";2import { extractDomain } from "../shared/domain";34function getRegistrableDomain(hostname: string): string {5  return extractDomain(hostname) ?? hostname;6}78export function checkFormAction(form: HTMLFormElement | null, expectedDomain: string): boolean {9  if (!form?.action) return true;10  try {11    const actionUrl = new URL(form.action);12    // PB-26-008: a <form> element is a strong origin signal, so require13    // https and a host on the expected domain or a subdomain of it — not14    // merely any site sharing the registrable domain (eTLD+1).15    if (actionUrl.protocol !== "https:") return false;16    const host = actionUrl.hostname;17    if (getRegistrableDomain(host) !== expectedDomain) return false;18    if (host !== expectedDomain && !host.endsWith(`.${expectedDomain}`)) {19      return false;20    }21  } catch {}22  return true;23}2425// Single decision for every fill path: PB-26-105 was three copies of this26// condition with one of them missing, so a gate refusal in fillFields still27// let the TOTP and confirm-password paths write into the hijacked form.28export function fillAllowed(29  form: HTMLFormElement | null | undefined,30  expectedDomain?: string | null31): boolean {32  if (!form || !expectedDomain) return true;33  return checkFormAction(form, expectedDomain);34}3536// Returns false when the form-action gate refused — callers must not write37// anything else into the form after a refusal.38export function fillFields(39  usernameEl: HTMLInputElement | null,40  passwordEl: HTMLInputElement | null,41  username: string,42  password: string,43  form?: HTMLFormElement | null,44  expectedDomain?: string,45  emailEl?: HTMLInputElement | null,46  email?: string47): boolean {48  if (!fillAllowed(form, expectedDomain)) {49    return false;50  }51  // Fill username regardless of visibility — on SPA step-by-step login flows52  // the username input may be visually hidden but still tracked by the form.53  if (usernameEl && username) {54    setNativeValue(usernameEl, username);55  }56  if (passwordEl && isVisible(passwordEl)) {57    setNativeValue(passwordEl, password);58  }59  if (emailEl && email && isVisible(emailEl)) {60    setNativeValue(emailEl, email);61  }62  return true;63}6465// Distributes a TOTP code character-by-character into an ordered array of66// single-character input boxes (the individual digit entry pattern used by67// some 2FA flows). Triggers native input+change events on each box so68// React/Vue controlled inputs update correctly.69export function fillOTPDigitBoxes(boxes: HTMLInputElement[], code: string): void {70  const digits = code.replace(/\s/g, "").split("");71  boxes.forEach((box, i) => {72    if (i < digits.length) setNativeValue(box, digits[i]!);73  });74}7576export function fillTOTPCode(77  totpEl: HTMLInputElement | null,78  code: string,79  form?: HTMLFormElement | null,80  expectedDomain?: string81): boolean {82  if (!totpEl || !code || !isVisible(totpEl)) return false;83  if (totpEl.value.trim() !== "") return false;84  if (!fillAllowed(form, expectedDomain)) return false;85  setNativeValue(totpEl, code);86  return true;87}8889// React tracks input values via a synthetic event system that does not see90// direct `.value =` assignment. We must call the native HTMLInputElement91// value setter (pulled off the prototype) and then dispatch the real input92// and change events so React re-reads the new value. Without this, the93// field reads as empty to the form's controlled-input state.94export function setNativeValue(95  el: HTMLInputElement | HTMLTextAreaElement,96  value: string97): void {98  const proto =99    el instanceof HTMLTextAreaElement100      ? window.HTMLTextAreaElement.prototype101      : window.HTMLInputElement.prototype;102  const nativeValueSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set;103  nativeValueSetter?.call(el, value);104  el.dispatchEvent(new Event("input", { bubbles: true }));105  el.dispatchEvent(new Event("change", { bubbles: true }));106}107108export interface CardFillFields {109  number: HTMLInputElement | null;110  expMonth: HTMLInputElement | null;111  expYear: HTMLInputElement | null;112  expiryCombo: HTMLInputElement | null;113  cvc: HTMLInputElement | null;114  name: HTMLInputElement | null;115}116117export function fillCardForm(fields: CardFillFields, card: { cardNumber: string; expMonth: string; expYear: string; cvc: string; cardholderName: string }): void {118  // PB-26-007: same visibility gate as the password/email path in119  // fillFields — a crafted page must not feed card data into hidden fields.120  const raw = card.cardNumber.replace(/\s/g, '');121  if (fields.number && isVisible(fields.number)) setNativeValue(fields.number, formatCardNumber(raw));122  if (fields.expiryCombo && isVisible(fields.expiryCombo)) {123    setNativeValue(fields.expiryCombo, `${card.expMonth}/${card.expYear.slice(-2)}`);124  } else {125    if (fields.expMonth && isVisible(fields.expMonth)) setNativeValue(fields.expMonth, card.expMonth);126    if (fields.expYear && isVisible(fields.expYear)) setNativeValue(fields.expYear, card.expYear);127  }128  if (fields.cvc && isVisible(fields.cvc)) setNativeValue(fields.cvc, card.cvc);129  if (fields.name && isVisible(fields.name)) setNativeValue(fields.name, card.cardholderName);130}131132export function formatCardNumber(raw: string): string {133  if (/^3[47]/.test(raw)) return raw.replace(/(\d{4})(\d{6})(\d{5})/, '$1 $2 $3');134  return raw.replace(/(\d{4})(?=\d)/g, '$1 ').trim();135}136137export function detectCardNetwork(number: string): import('../shared/types').CardNetwork {138  const n = number.replace(/\s/g, '');139  if (/^4/.test(n)) return 'visa';140  if (/^5[1-5]|^2[2-7]/.test(n)) return 'mastercard';141  if (/^3[47]/.test(n)) return 'amex';142  if (/^6(?:011|5)/.test(n)) return 'discover';143  return 'unknown';144}145146export function passesLuhn(number: string): boolean {147  const digits = number.replace(/\s/g, '').split('').map(Number);148  let sum = 0, isEven = false;149  for (let i = digits.length - 1; i >= 0; i--) {150    let d = digits[i]!;151    if (isEven) { d *= 2; if (d > 9) d -= 9; }152    sum += d;153    isEven = !isEven;154  }155  return sum % 10 === 0;156}157158export function extractLast4(number: string): string {159  return number.replace(/\s/g, '').slice(-4);160}