b2e695e079b3

fix: PCH-01-018 gate destructive message types (DESTROY_VAULT/CLEAR_VAULT_ENTRIES/RESTORE_VAULT_SNAPSHOT/RESTORE_FROM_CODEX) to extension-page senders — content-script XSS can no longer escalate to vault destruction (v3.8.1)

time
2026-06-10 20:10 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
32a9f43d7d6b
3 files src/background/messages.adversarial.test.ts+58 −8src/background/messages.test.ts+3 −2src/background/messages.ts+24 −0
src/background/messages.adversarial.test.ts+58 −8
@@ -79,6 +79,7 @@7979     const chromeMock = {8080       runtime: {8181         id: "peach-extension",82+        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,8283         onMessage: {8384           addListener: vi.fn((fn: MessageListener) => {8485             listener = fn;@@ -155,18 +156,24 @@155156     await vault.ensureSettingsLoaded();156157   });157158 158-  async function dispatch(message: Message): Promise<MessageResponse> {159+  async function dispatch(160+    message: Message,161+    sender: chrome.runtime.MessageSender = { id: "peach-extension" } as chrome.runtime.MessageSender,162+  ): Promise<MessageResponse> {159163     if (!listener) throw new Error("listener not registered");160164     return new Promise<MessageResponse>((resolve) => {161-      const keepOpen = listener!(162-        message,163-        { id: "peach-extension" } as chrome.runtime.MessageSender,164-        resolve165-      );165+      const keepOpen = listener!(message, sender, resolve);166166       expect(keepOpen).toBe(true);167167     });168168   }169169 170+  // PCH-01-018: destructive ops require an extension-page sender; the adversarial171+  // RESTORE_FROM_CODEX payload tests must use this to reach payload validation.172+  const EXTENSION_PAGE_SENDER = {173+    id: "peach-extension",174+    url: "chrome-extension://peach-extension/popup.html",175+  } as chrome.runtime.MessageSender;176+170177   async function setupUnlockedVault() {171178     const vault = await import("./vault");172179     await vault.createVault("test-pass-12345");@@ -493,7 +500,7 @@493500           masterPassphrase: "secret",494501           ...overrides,495502         };496-        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload });503+        const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);497504         expect(r).toBeDefined();498505         expect(r.ok).toBe(false);499506       });@@ -503,7 +510,7 @@503510       const r = await dispatch({504511         type: "RESTORE_FROM_CODEX",505512         payload: null,506-      } as unknown as Message);513+      } as unknown as Message, EXTENSION_PAGE_SENDER);507514       expect(r).toBeDefined();508515       expect(r.ok).toBe(false);509516     });@@ -658,6 +665,49 @@658665       expect(r.ok).toBe(true);659666       expect((r as { data: Record<string, unknown> }).data.username).toBe("june");660667     });668+  });669+670+  // ── Destructive-op sender gate (PCH-01-018) ─────────────────────────────671+672+  describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {673+    async function dispatchFrom(674+      message: Message,675+      sender: chrome.runtime.MessageSender,676+    ): Promise<MessageResponse> {677+      if (!listener) throw new Error("listener not registered");678+      return new Promise<MessageResponse>((resolve) => {679+        listener!(message, sender, resolve);680+      });681+    }682+    const DESTRUCTIVE = [683+      "DESTROY_VAULT",684+      "CLEAR_VAULT_ENTRIES",685+      "RESTORE_VAULT_SNAPSHOT",686+      "RESTORE_FROM_CODEX",687+    ] as const;688+    const contentScript = {689+      id: "peach-extension",690+      url: "https://evil.test/",691+    } as chrome.runtime.MessageSender;692+    const extensionPage = {693+      id: "peach-extension",694+      url: "chrome-extension://peach-extension/popup.html",695+    } as chrome.runtime.MessageSender;696+697+    for (const type of DESTRUCTIVE) {698+      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {699+        await setupUnlockedVault();700+        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);701+        expect(r).toEqual({ ok: false, error: "invalid_sender" });702+      });703+704+      it(`${type} from an extension-page sender passes the origin gate`, async () => {705+        await setupUnlockedVault();706+        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);707+        // It may still fail downstream validation, but never on the origin gate.708+        expect(r.error).not.toBe("invalid_sender");709+      });710+    }661711   });662712 663713   // ── ADD_ENTRY adversarial when locked ───────────────────────────────────

view file

src/background/messages.test.ts+3 −2
@@ -36,6 +36,7 @@3636     const chromeMock = {3737       runtime: {3838         id: "peach-extension",39+        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,3940         onMessage: {4041           addListener: vi.fn((fn: MessageListener) => {4142             listener = fn;@@ -818,7 +819,7 @@818819     storage.peach_license = "PEACH-PRO-TEST-KEY";819820     storage.peach_pro_celebrated = true;820821 821-    const res = await dispatch({ type: "DESTROY_VAULT" });822+    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });822823     expect(res).toMatchObject({ ok: true });823824 824825     // Vault data is gone…@@ -921,7 +922,7 @@921922     const vault = await import("./vault");922923     await vault.createVault("correct-pass-12345");923924 924-    const res = await dispatch({ type: "DESTROY_VAULT" });925+    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });925926     expect(res).toMatchObject({ ok: true });926927 927928     expect(storage.peach_vault_a).toBeUndefined();

view file

src/background/messages.ts+24 −0
@@ -134,6 +134,17 @@134134 135135 const FEEDBACK_ENDPOINT = "";136136 137+// PCH-01-018: operations that irreversibly destroy or replace vault contents.138+// These are gated to trusted extension-page senders in handleMessage; the139+// confirmation UX otherwise lives only in the popup. Keep this in sync when140+// adding new destructive handlers.141+const DESTRUCTIVE_MESSAGE_TYPES: ReadonlySet<string> = new Set([142+  "DESTROY_VAULT",143+  "CLEAR_VAULT_ENTRIES",144+  "RESTORE_VAULT_SNAPSHOT",145+  "RESTORE_FROM_CODEX",146+]);147+137148 // LV-3: minimum interval between non-test canary pings per webhook+entry.138149 const CANARY_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour139150 @@ -251,6 +262,19 @@251262       sender.id !== chrome.runtime.id252263     ) {253264       return { ok: false, error: "invalid_sender" };265+    }266+267+    // PCH-01-018: irreversible operations must originate from a trusted extension268+    // page (popup/options), never from a content script. The sender.id check above269+    // only proves the message came from *our* extension — a content script injected270+    // into an attacker page passes it. A single XSS-class bug in the injected UI271+    // would otherwise escalate to vault destruction. Extension pages carry an272+    // extension-origin sender.url; content scripts carry a page URL.273+    if (DESTRUCTIVE_MESSAGE_TYPES.has(msg.type)) {274+      const extensionOrigin = chrome.runtime.getURL("");275+      if (!sender.url?.startsWith(extensionOrigin)) {276+        return { ok: false, error: "invalid_sender" };277+      }254278     }255279 256280     if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {

view file