12953927f2d5
fix: PCH-01-006 GET_ENTRY_FOR_EDIT origin re-validation mirroring MV-1 fill path; tab-context domain gate + explicitSummon escape hatch, popup path unbroken (v3.8.1)
- time
- 2026-06-10 19:29 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- cf11fa1a0223
↓ peach-browser-12953927.tar.gzatlas at this save
files changed2 files
src/background/messages.adversarial.test.ts+67 −0src/background/messages.ts+35 −1src/background/messages.adversarial.test.ts+67 −0
@@ -593,6 +593,73 @@593593 });594594 });595595 596+ // ── GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006) ────────────────597+598+ describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {599+ async function dispatchFrom(600+ message: Message,601+ sender: chrome.runtime.MessageSender,602+ ): Promise<MessageResponse> {603+ if (!listener) throw new Error("listener not registered");604+ return new Promise<MessageResponse>((resolve) => {605+ listener!(message, sender, resolve);606+ });607+ }608+609+ async function addGithubEntry() {610+ const vault = await import("./vault");611+ await vault.createVault("test-pass-12345");612+ return vault.addEntry({613+ title: "GitHub",614+ username: "june",615+ password: "x",616+ url: "https://github.com",617+ type: "login",618+ });619+ }620+621+ it("tab-context request on the entry's domain returns the full entry", async () => {622+ const e = await addGithubEntry();623+ const r = await dispatchFrom(624+ { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,625+ { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,626+ );627+ expect(r.ok).toBe(true);628+ expect((r as { data: Record<string, unknown> }).data.username).toBe("june");629+ });630+631+ it("tab-context request on a mismatched domain returns entry_not_found with no leak", async () => {632+ const e = await addGithubEntry();633+ const r = await dispatchFrom(634+ { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,635+ { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,636+ );637+ expect(r.ok).toBe(false);638+ expect(r.error).toBe("entry_not_found");639+ expect((r as { data?: unknown }).data).toBeUndefined();640+ });641+642+ it("explicitSummon allows a cross-domain tab-context request", async () => {643+ const e = await addGithubEntry();644+ const r = await dispatchFrom(645+ { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,646+ { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,647+ );648+ expect(r.ok).toBe(true);649+ expect((r as { data: Record<string, unknown> }).data.username).toBe("june");650+ });651+652+ it("popup context (no tab/url) skips the origin check and returns the entry", async () => {653+ const e = await addGithubEntry();654+ const r = await dispatchFrom(655+ { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,656+ { id: "peach-extension" } as chrome.runtime.MessageSender,657+ );658+ expect(r.ok).toBe(true);659+ expect((r as { data: Record<string, unknown> }).data.username).toBe("june");660+ });661+ });662+596663 // ── ADD_ENTRY adversarial when locked ───────────────────────────────────597664 598665 describe("ADD_ENTRY locked-state safety", () => {src/background/messages.ts+35 −1
@@ -732,9 +732,43 @@732732 // The passkey credential is still stripped — the edit form never touches it.733733 case "GET_ENTRY_FOR_EDIT": {734734 if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };735- const { id } = msg.payload as { id: string };735+ const { id, explicitSummon } = msg.payload as {736+ id: string;737+ explicitSummon?: boolean;738+ };736739 const entry = vault.getEntryById(id);737740 if (!entry) return { ok: false, error: "entry_not_found" };741+742+ // PCH-01-006: re-validate the browser-attested sender origin, mirroring743+ // GET_ENTRY_FOR_FILL (MV-1). The edit channel ships the raw TOTP seed and744+ // full password history — at least as sensitive as the fill response — so745+ // the boundary must be enforced here, not only by UI reachability. Today746+ // the sole caller is the popup (no sender.tab, so this is a structural747+ // no-op), but a future tab-context caller or an id leak would otherwise748+ // inherit a full-credential oracle. Unlike the fill path we SKIP (not749+ // refuse) when no sender domain resolves, to keep the popup caller working.750+ if (sender.tab !== undefined || sender.url !== undefined) {751+ let senderDomain: string | null = null;752+ try {753+ const rawUrl = sender.tab?.url ?? sender.url ?? "";754+ senderDomain = rawUrl ? extractDomain(new URL(rawUrl).hostname) : null;755+ } catch {756+ senderDomain = null;757+ }758+ if (senderDomain) {759+ const entryDomains = new Set<string>();760+ const primary = extractDomain(entry.url ?? "");761+ if (primary) entryDomains.add(primary);762+ for (const extra of entry.additionalUrls ?? []) {763+ const d = extractDomain(extra);764+ if (d) entryDomains.add(d);765+ }766+ if (explicitSummon !== true && !entryDomains.has(senderDomain)) {767+ return { ok: false, error: "entry_not_found" }; // no id oracle768+ }769+ }770+ }771+738772 const { passkey: _passkey, ...safeEntry } = entry;739773 return { ok: true, data: safeEntry };740774 }