2ee6e8b00769

fix: card autofill applies the visibility gate and form-action check requires https on-domain-or-subdomain (PB-26-007, PB-26-008) — hidden card fields no longer filled, crafted forms on sibling registrable domains or plain http refused; TDD regression tests; bump 5.0.7→5.0.8

time
2026-08-30 00:12 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
pb-26 audit fixes
parent
a838d84d8945
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/fill.test.ts+83 −0src/content/fill.ts+15 −8
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.7",4+  "version": "5.0.8",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.0.7",5+  "version": "5.0.8",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.7",3+  "version": "5.0.8",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/content/fill.test.ts+83 −0
@@ -54,6 +54,36 @@5454     expect(password.value).toBe("");5555   });5656 57+  // PB-26-008: where a <form> exists, the action must be https and on the58+  // expected domain or a subdomain of it — not merely the same eTLD+1.59+  it("fillFields refuses a plain-http form action (PB-26-008)", () => {60+    const form = document.createElement("form");61+    form.action = "http://github.com/login";62+    const username = makeVisibleInput("text");63+    const password = makeVisibleInput("password");64+    form.append(username, password);65+    document.body.appendChild(form);66+67+    fillFields(username, password, "june", "hunter2", form, "github.com");68+69+    expect(username.value).toBe("");70+    expect(password.value).toBe("");71+  });72+73+  it("fillFields allows an https form action on a subdomain of the expected domain (PB-26-008)", () => {74+    const form = document.createElement("form");75+    form.action = "https://auth.github.com/session";76+    const username = makeVisibleInput("text");77+    const password = makeVisibleInput("password");78+    form.append(username, password);79+    document.body.appendChild(form);80+81+    fillFields(username, password, "june", "hunter2", form, "github.com");82+83+    expect(username.value).toBe("june");84+    expect(password.value).toBe("hunter2");85+  });86+5787   it("fillFields still fills the username when the username field is hidden", () => {5888     const username = makeVisibleInput("text");5989     const password = makeVisibleInput("password");@@ -99,9 +129,62 @@99129   function makeInput(attrs: Record<string, string> = {}): HTMLInputElement {100130     const input = document.createElement("input");101131     for (const [k, v] of Object.entries(attrs)) input.setAttribute(k, v);132+    // jsdom rects are all-zero, which isVisible treats as hidden — stub a133+    // real rect so these inputs count as visible unless a test hides them.134+    Object.defineProperty(input, "getBoundingClientRect", {135+      configurable: true,136+      value: () => ({137+        width: 100,138+        height: 20,139+        top: 0,140+        left: 0,141+        right: 100,142+        bottom: 20,143+        x: 0,144+        y: 0,145+        toJSON: () => ({}),146+      }),147+    });102148     document.body.appendChild(input);103149     return input;104150   }151+152+  function makeHiddenInput(attrs: Record<string, string> = {}): HTMLInputElement {153+    const input = makeInput(attrs);154+    Object.defineProperty(input, "getBoundingClientRect", {155+      configurable: true,156+      value: () => ({157+        width: 0,158+        height: 0,159+        top: 0,160+        left: 0,161+        right: 0,162+        bottom: 0,163+        x: 0,164+        y: 0,165+        toJSON: () => ({}),166+      }),167+    });168+    return input;169+  }170+171+  // PB-26-007: card fill must apply the same visibility gate as the172+  // password path — a crafted page must not be able to exfiltrate card173+  // data into hidden fields.174+  it("fillCardForm — skips hidden fields (PB-26-007)", () => {175+    const number = makeHiddenInput({ type: "text" });176+    const cvc = makeHiddenInput({ type: "text" });177+    const name = makeInput({ type: "text" });178+179+    fillCardForm(180+      { number, expMonth: null, expYear: null, expiryCombo: null, cvc, name },181+      { cardNumber: "4111111111111111", expMonth: "05", expYear: "2028", cvc: "123", cardholderName: "Jane Smith" }182+    );183+184+    expect(number.value).toBe("");185+    expect(cvc.value).toBe("");186+    expect(name.value).toBe("Jane Smith");187+  });105188 106189   it("fillCardForm — fills number, expiry, CVC, and name fields", () => {107190     const number = makeInput({ type: "text" });

view file

src/content/fill.ts+15 −8
@@ -9,8 +9,13 @@99   if (!form?.action) return true;1010   try {1111     const actionUrl = new URL(form.action);12-    const actionDomain = getRegistrableDomain(actionUrl.hostname);13-    if (actionDomain !== expectedDomain) {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}`)) {1419       return false;1520     }1621   } catch {}@@ -93,16 +98,18 @@9398 }9499 95100 export function fillCardForm(fields: CardFillFields, card: { cardNumber: string; expMonth: string; expYear: string; cvc: string; cardholderName: string }): void {101+  // PB-26-007: same visibility gate as the password/email path in102+  // fillFields — a crafted page must not feed card data into hidden fields.96103   const raw = card.cardNumber.replace(/\s/g, '');97-  if (fields.number) setNativeValue(fields.number, formatCardNumber(raw));98-  if (fields.expiryCombo) {104+  if (fields.number && isVisible(fields.number)) setNativeValue(fields.number, formatCardNumber(raw));105+  if (fields.expiryCombo && isVisible(fields.expiryCombo)) {99106     setNativeValue(fields.expiryCombo, `${card.expMonth}/${card.expYear.slice(-2)}`);100107   } else {101-    if (fields.expMonth) setNativeValue(fields.expMonth, card.expMonth);102-    if (fields.expYear) setNativeValue(fields.expYear, card.expYear);108+    if (fields.expMonth && isVisible(fields.expMonth)) setNativeValue(fields.expMonth, card.expMonth);109+    if (fields.expYear && isVisible(fields.expYear)) setNativeValue(fields.expYear, card.expYear);103110   }104-  if (fields.cvc) setNativeValue(fields.cvc, card.cvc);105-  if (fields.name) setNativeValue(fields.name, card.cardholderName);111+  if (fields.cvc && isVisible(fields.cvc)) setNativeValue(fields.cvc, card.cvc);112+  if (fields.name && isVisible(fields.name)) setNativeValue(fields.name, card.cardholderName);106113 }107114 108115 export function formatCardNumber(raw: string): string {

view file