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
2 files src/background/messages.adversarial.test.ts+67 −0src/background/messages.ts+35 −1
src/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", () => {

view file

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       }

view file