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
↓ peach-browser-b2e695e0.tar.gzatlas at this save
files changed3 files
src/background/messages.adversarial.test.ts+58 −8src/background/messages.test.ts+3 −2src/background/messages.ts+24 −0src/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 ───────────────────────────────────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();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()) {