a838d84d8945

fix: fill/edit origin attestation uses the browser-attested frame url with tab url as fallback (PB-26-005) — all_frames content scripts validate against their own frame origin, not the top page; tests pin frame-url precedence in both directions; bump 5.0.6→5.0.7

time
2026-08-30 00:09 UTC · 1 month ago
machine
koh2
agent
glm-5.3-flash
session
pb-26 audit fixes
parent
c9312ae6559e
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.edit-origin.test.ts+56 −0src/background/messages.ts+9 −4
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.6",4+  "version": "5.0.7",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.6",5+  "version": "5.0.7",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.6",3+  "version": "5.0.7",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.edit-origin.test.ts+56 −0
@@ -200,6 +200,62 @@200200     expect(data.title).toBe("Same Domain Entry");201201   });202202 203+  // PB-26-005: the frame url (sender.url) is authoritative over the top-frame204+  // sender.tab.url for an all_frames content script.205+  it("PB-26-005: iframe sender.url matching the entry wins over a non-matching top-frame tab.url", async () => {206+    const entry = await vault.addEntry({207+      type: "login",208+      title: "Frame Entry",209+      username: "framed",210+      password: "pw12345",211+      url: "https://iframe.example.net",212+    });213+214+    // A cross-origin iframe on a foreign top-level page: the frame is the215+    // real requester. Under the old tab?.url preference this was refused.216+    const frameSender = {217+      tab: { url: "https://top.example.com/page" },218+      url: "https://iframe.example.net/form",219+    } as unknown as chrome.runtime.MessageSender;220+221+    const result = await messages.handleMessage(222+      {223+        type: "GET_ENTRY_FOR_EDIT",224+        payload: { id: entry.id, explicitSummon: false },225+      },226+      frameSender,227+    );228+229+    const { ok } = result as { ok: boolean };230+    expect(ok).toBe(true);231+  });232+233+  it("PB-26-005: attacker iframe sender.url denies even when the top frame matches the entry", async () => {234+    const entry = await vault.addEntry({235+      type: "login",236+      title: "Top Entry",237+      username: "top",238+      password: "pw12345",239+      url: "https://example.com",240+    });241+242+    const attackerFrameSender = {243+      tab: { url: "https://example.com/page" },244+      url: "https://attacker.example.net/frame",245+    } as unknown as chrome.runtime.MessageSender;246+247+    const result = await messages.handleMessage(248+      {249+        type: "GET_ENTRY_FOR_EDIT",250+        payload: { id: entry.id, explicitSummon: false },251+      },252+      attackerFrameSender,253+    );254+255+    expect((result as { ok: boolean; error?: string }).ok).toBe(false);256+    expect((result as { error?: string }).error).toBe("entry_not_found");257+  });258+203259   // T-022e: GET_ENTRY_FOR_EDIT with explicitSummon=true bypasses the domain check.204260   // The explicit summon flag is set only by user-initiated keyboard shortcuts,205261   // never by passive autofill. It allows the edit form to open for entries on

view file

src/background/messages.ts+9 −4
@@ -731,14 +731,17 @@731731         // login that ALSO has a passkey is still fillable for its password.732732         if (isPasskeyOnly(entry)) return { ok: false, error: "passkey_not_fillable" };733733 734-        // MV-1: re-validate the entry against the requesting tab's origin.735-        // sender.tab.url is browser-attested — a page cannot forge it. When734+        // MV-1: re-validate the entry against the requesting frame's origin.735+        // sender.url / sender.tab.url are browser-attested — a page cannot736+        // forge them. PB-26-005: the FRAME url (sender.url) is authoritative737+        // for an all_frames content script; the top-frame tab.url would738+        // misattribute a cross-origin iframe's request to the top page. When736739         // the request does not come from a tab at all (popup surfaces use737740         // GET_ENTRY_FOR_EDIT, not this), refuse.738741         if (sender.tab !== undefined || sender.url !== undefined) {739742           let senderDomain: string | null = null;740743           try {741-            const rawUrl = sender.tab?.url ?? sender.url ?? "";744+            const rawUrl = sender.url ?? sender.tab?.url ?? "";742745             senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;743746           } catch {744747             senderDomain = null;@@ -818,10 +821,12 @@818821         // no-op), but a future tab-context caller or an id leak would otherwise819822         // inherit a full-credential oracle. Unlike the fill path we SKIP (not820823         // refuse) when no sender domain resolves, to keep the popup caller working.824+        // PB-26-005: the frame url (sender.url) is authoritative over the825+        // top-frame sender.tab.url — mirrors the fill path.821826         if (sender.tab !== undefined || sender.url !== undefined) {822827           let senderDomain: string | null = null;823828           try {824-            const rawUrl = sender.tab?.url ?? sender.url ?? "";829+            const rawUrl = sender.url ?? sender.tab?.url ?? "";825830             senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;826831           } catch {827832             senderDomain = null;

view file