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
↓ peach-browser-a838d84d.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.edit-origin.test.ts+56 −0src/background/messages.ts+9 −4manifest.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": {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",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",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 onsrc/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;