151df7df811c
PB-26-201 authorization boundary and capability remediation (v5.0.54)
- time
- 2026-09-02 12:41 UTC · 1 month ago
- machine
- koh2
- agent
- claude-sonnet-4-6
- session
- PB-26-201 authorization
- parent
- 9371b98e40b5
↓ peach-browser-151df7df.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/commands.ts+5 −1src/background/message-policy.ts+2 −2src/background/messages.ts+33 −19src/content/index.ts+12 −9src/content/inject.ts+4 −4manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.53",4+ "version": "5.0.54",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.53",5+ "version": "5.0.54",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.53",3+ "version": "5.0.54",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/commands.ts+5 −1
@@ -7,6 +7,8 @@77 * 3. Content script not ready → open popup fallback88 */99 10+import { createSummonCapability } from "./messages";11+1012 const PING_TIMEOUT_MS = 150;1113 1214 async function pingContentScript(tabId: number): Promise<boolean> {@@ -59,7 +61,9 @@5961 // Content script fully handles SUMMON_AUTOFILL with source:"shortcut":6062 // shows the autofill selector when a form is present, or falls back to6163 // a search-mode palette that copies to clipboard. No popup fallback needed.62- chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut" }).catch(() => {});64+ const origin = new URL(tab.url).origin;65+ const capability = createSummonCapability(tabId, origin, 0);66+ chrome.tabs.sendMessage(tabId, { type: "SUMMON_AUTOFILL", source: "shortcut", capability }).catch(() => {});6367 return;6468 }6569 src/background/message-policy.ts+2 −2
@@ -77,7 +77,7 @@7777 GET_VAULT_STATUS: "extension-page-only",7878 GET_ADVANCED_HEALTH_REPORT: "extension-page-only",7979 DEACTIVATE_LICENSE: "extension-page-only",80- GET_IDENTITIES: "content-script-allowed",80+ GET_IDENTITIES: "extension-page-only",8181 DESTROY_VAULT: "extension-page-only",8282 COMPLETE_FIREFOX_PASSKEY_CREATE: "extension-page-only",8383 VALIDATE_RECOVERY_WORDS: "extension-page-only",@@ -107,7 +107,7 @@107107 COPY: "content-script-allowed",108108 FIRE_CANARY_PING: "content-script-allowed",109109 GET_ENTRY_FOR_FILL: "content-script-allowed",110- GET_ENTRY_FOR_EDIT: "content-script-allowed",110+ GET_ENTRY_FOR_EDIT: "extension-page-only",111111 GET_SETTINGS: "content-script-allowed",112112 GET_SITE_PASSWORD_POLICY: "content-script-allowed",113113 RECORD_MANUAL_SUMMON: "content-script-allowed",src/background/messages.ts+33 −19
@@ -107,6 +107,28 @@107107 } from "./sync-trigger";108108 import { isMessageSenderAllowed } from "./message-policy";109109 110+const SUMMON_CAPABILITY_TTL_MS = 30_000;111+type SummonCapability = { tabId: number; frameId: number; origin: string; operation: "inventory" | "fill"; entryId?: string; expiresAt: number };112+const summonCapabilities = new Map<string, SummonCapability>();113+114+export function createSummonCapability(tabId: number, origin: string, frameId = 0): string {115+ const token = crypto.randomUUID();116+ summonCapabilities.set(token, { tabId, frameId, origin, operation: "inventory", expiresAt: Date.now() + SUMMON_CAPABILITY_TTL_MS });117+ return token;118+}119+120+function consumeSummonCapability(token: unknown, sender: Partial<chrome.runtime.MessageSender>, operation: "inventory" | "fill", entryId?: string): boolean {121+ if (typeof token !== "string") return false;122+ const cap = summonCapabilities.get(token);123+ if (!cap || cap.expiresAt <= Date.now() || sender.tab?.id !== cap.tabId || (sender.frameId ?? 0) !== cap.frameId || attestedSenderOrigin(sender) !== cap.origin) return false;124+ if (cap.operation !== operation) return false;125+ if (operation === "inventory") cap.operation = "fill";126+ else if (cap.entryId === undefined) cap.entryId = entryId;127+ else if (cap.entryId !== entryId) return false;128+ else summonCapabilities.delete(token);129+ return true;130+}131+110132 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in111133 // every production build — verified in the bundled output). Mirrors the112134 // create-path bypass in passkey.ts so the Playwright passkey smoke can enable@@ -767,27 +789,18 @@767789 if (!vault.isUnlocked()) {768790 return { ok: false, error: "vault_locked" };769791 }770- let { includeAll } = msg.payload as { includeAll?: boolean };771- const { domain, kind, userGesture } = msg.payload as {792+ const { domain, kind, capability } = msg.payload as {772793 domain: string;773794 kind?: "login" | "card" | "totp";774- userGesture?: boolean;795+ capability?: string;775796 };776- // MV-2: the vault-wide inventory (every title/username/email/url)777- // may only leave the background on an explicit user gesture — the778- // user opening the search affordance — never on passive form focus.779- // userGesture is self-declared by our own content script; the value780- // of this gate is that PASSIVE code paths (which never set it)781- // cannot ship the inventory, shrinking what a compromised page that782- // merely triggers focus events can observe.783- if (includeAll && userGesture !== true) {784- includeAll = false;785- }797+ const includeAll = consumeSummonCapability(capability, sender, "inventory");786798 // kind="card": return saved cards as compact summaries for the787799 // inline card silver platter. Cards are not domain-scoped — every788800 // saved card is a candidate on every checkout page — so domain is789801 // ignored when kind === "card".790802 if (kind === "card") {803+ if (!includeAll) return { ok: true, data: [] };791804 const cards = vault.getEntries().filter((e) => e.type === "card");792805 return {793806 ok: true,@@ -863,9 +876,9 @@863876 }864877 case "GET_ENTRY_FOR_FILL": {865878 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };866- const { id, explicitSummon } = msg.payload as {879+ const { id, capability } = msg.payload as {867880 id: string;868- explicitSummon?: boolean;881+ capability?: string;869882 };870883 const entry = vault.getEntryById(id);871884 if (!entry) return { ok: false, error: "entry_not_found" };@@ -902,7 +915,7 @@902915 // explicitSummon=true means the user actively picked this entry903916 // from the cross-domain search affordance — an intentional904917 // off-domain fill. Passive fill paths must stay domain-bound.905- if (explicitSummon !== true && !entryDomains.has(senderDomain)) {918+ if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {906919 // Domain mismatch — refuse silently (no oracle about which ids exist).907920 return { ok: false, error: "entry_not_found" };908921 }@@ -948,10 +961,11 @@948961 // the inline popup, never during passive autofill on an arbitrary page.949962 // The passkey credential is still stripped — the edit form never touches it.950963 case "GET_ENTRY_FOR_EDIT": {964+ if (isPageSender(sender)) return { ok: false, error: "invalid_sender" };951965 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };952- const { id, explicitSummon } = msg.payload as {966+ const { id, capability } = msg.payload as {953967 id: string;954- explicitSummon?: boolean;968+ capability?: string;955969 };956970 const entry = vault.getEntryById(id);957971 if (!entry) return { ok: false, error: "entry_not_found" };@@ -982,7 +996,7 @@982996 const d = extractDomain(extra);983997 if (d) entryDomains.add(d);984998 }985- if (explicitSummon !== true && !entryDomains.has(senderDomain)) {999+ if (!entryDomains.has(senderDomain) && !consumeSummonCapability(capability, sender, "fill", id)) {9861000 return { ok: false, error: "entry_not_found" }; // no id oracle9871001 }9881002 }src/content/index.ts+12 −9
@@ -158,7 +158,7 @@158158 }, 2000);159159 }160160 161-function showSearchPalette(): void {161+function showSearchPalette(capability?: string): void {162162 const host = document.createElement("div");163163 host.style.cssText = "position:fixed;top:80px;left:50%;transform:translateX(-50%);z-index:2147483647;width:340px;";164164 document.body.appendChild(host);@@ -253,7 +253,7 @@253253 row.onclick = async () => {254254 // MV-1: vault-wide search list is a cross-domain summon — the user255255 // explicitly picked this entry to fill on this page.256- const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, explicitSummon: true } });256+ const resp = await chrome.runtime.sendMessage({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability } });257257 const r = resp as { ok: boolean; data?: { password?: string } };258258 if (r.ok && r.data?.password) {259259 let alreadyWritten = false;@@ -389,11 +389,12 @@389389 async function fetchAutofillEntries(390390 domain: string,391391 includeAll = false,392- userGesture = false392+ _userGesture = false,393+ capability?: string393394 ): Promise<{ entries: SelectorEntry[]; locked: boolean }> {394395 const response = await chrome.runtime.sendMessage({395396 type: "AUTOFILL_QUERY",396- payload: { domain, includeAll, userGesture },397+ payload: { domain, includeAll, capability },397398 });398399 const res = response as { ok: boolean; data?: SelectorEntry[]; error?: string };399400 if (!res.ok && res.error === "vault_locked") {@@ -595,7 +596,8 @@595596 596597 export /* @testable */ async function handleFieldInteraction(597598 anchorField?: HTMLInputElement | null,598- explicitSummon = false599+ explicitSummon = false,600+ capability?: string599601 ): Promise<void> {600602 const field = anchorField ?? getDeepActiveInput();601603 if (!field) return;@@ -707,7 +709,7 @@707709 // world — and never hands out the entry ids that made MV-1 a full-vault708710 // primitive.709711 const loadSearchableEntries = async (): Promise<SelectorEntry[]> =>710- (await fetchAutofillEntries(domain, true, true)).entries;712+ (await fetchAutofillEntries(domain, true, true, capability)).entries;711713 712714 if (passkeyToastActive) return;713715 @@ -1080,7 +1082,8 @@10801082 return true;10811083 }10821084 if (msg?.type === "SUMMON_AUTOFILL") {1083- const source = (msg as { type: string; source?: string }).source;1085+ const source = (msg as { type: string; source?: string; capability?: string }).source;1086+ const capability = (msg as { capability?: string }).capability;10841087 const isShortcut = source === "shortcut";10851088 const isPasskeyUnlock = source === "passkey-unlock";10861089 @@ -1103,7 +1106,7 @@11031106 dismissLock = false;11041107 if (dismissLockTimer) { clearTimeout(dismissLockTimer); dismissLockTimer = null; }11051108 lastDismissedField = null;1106- void handleFieldInteraction(target);1109+ void handleFieldInteraction(target, true, capability);11071110 }11081111 } else if (isPasskeyUnlock) {11091112 // Vault is locked during a passkey GET request. Show the inline unlock@@ -1124,7 +1127,7 @@11241127 }11251128 } else if (isShortcut) {11261129 // Shortcut on a page with no login form — show clipboard-copy search mode.1127- showSearchPalette();1130+ showSearchPalette(capability);11281131 }11291132 11301133 // Record this domain for the recall system when triggered via shortcut.src/content/inject.ts+4 −4
@@ -2762,7 +2762,7 @@27622762 const resp = await chrome.runtime.sendMessage({27632763 type: "GET_ENTRY_FOR_FILL",27642764 // MV-1: user explicitly picked this entry from Peach's inline summon UI.2765- payload: { id: entry.id, explicitSummon: true },2765+ payload: { id: entry.id },27662766 }).catch(() => null) as { ok: boolean; data?: { password?: string } } | null;27672767 if (!resp?.ok || !resp.data?.password) return;27682768 let alreadyWritten = false;@@ -2802,7 +2802,7 @@28022802 const r = await chrome.runtime.sendMessage({28032803 type: "GET_ENTRY_FOR_FILL",28042804 // MV-1: explicit user summon of this entry's live TOTP code.2805- payload: { id: entry.id, explicitSummon: true },2805+ payload: { id: entry.id },28062806 }).catch(() => null) as { ok: boolean; data?: { totpCode?: string; totpRemaining?: number } } | null;28072807 if (!r?.ok || !r.data?.totpCode) return null;28082808 return { code: r.data.totpCode, remaining: r.data.totpRemaining ?? 30 };@@ -4332,7 +4332,7 @@43324332 const resp = await chrome.runtime.sendMessage({43334333 type: "GET_ENTRY_FOR_FILL",43344334 // MV-1: explicit user click to fill this card's details.4335- payload: { id: card.id, explicitSummon: true },4335+ payload: { id: card.id },43364336 }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;43374337 if (resp?.ok && resp.data) options.onFill(resp.data);43384338 setTimeout(() => dismiss(), 400);@@ -4364,7 +4364,7 @@43644364 const resp = await chrome.runtime.sendMessage({43654365 type: "GET_ENTRY_FOR_FILL",43664366 // MV-1: explicit user action on a card the user opened in Peach's UI.4367- payload: { id: card.id, explicitSummon: true },4367+ payload: { id: card.id },43684368 }).catch(() => null) as { ok: boolean; data?: import("../shared/types").VaultEntry } | null;43694369 return resp?.ok && resp.data ? resp.data : null;43704370 }