34ea8d9a31d0

fix: harden storage access and cover message sender matrix (v5.0.20)

time
2026-08-30 11:45 UTC · 1 month ago
machine
koh2
parent
8da868d4f0e1
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/entry.ts+2 −0src/background/message-policy.test.ts+38 −1src/background/message-policy.ts+37 −0src/background/messages.adversarial.test.ts+20 −0src/background/messages.ts+7 −36src/background/storage-access.test.ts+27 −0src/background/storage-access.ts+18 −0src/background/vault.rotate-sync-identity.test.ts+16 −7
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.19",4+  "version": "5.0.20",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

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.19",5+  "version": "5.0.20",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.0.19",3+  "version": "5.0.20",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/entry.ts+2 −0
@@ -26,6 +26,7 @@2626   selectProductionDialTargets,2727   setupMeshRelayListener,2828 } from "./mesh-relay-listener";29+import { restrictLocalStorageToTrustedContexts } from "./storage-access";2930 3031 // Keep alive for MV3 service worker3132 chrome.runtime.onInstalled.addListener(() => {});@@ -57,6 +58,7 @@5758 });5859 5960 export function start(): void {61+  void restrictLocalStorageToTrustedContexts();6062   setLockedSyncHooks({6163     refresh: refreshLockedSyncSpool,6264     afterUnlock: drainAndRefreshLockedSyncSpool,

view file

src/background/message-policy.test.ts+38 −1
@@ -1,6 +1,10 @@11 import { describe, expect, it } from "vitest";22 3-import { getMessageContext, MESSAGE_CONTEXT_POLICY } from "./message-policy";3+import {4+  getMessageContext,5+  isMessageSenderAllowed,6+  MESSAGE_CONTEXT_POLICY,7+} from "./message-policy";48 import type { MessageType } from "../shared/types";59 610 describe("message context policy", () => {@@ -55,5 +59,38 @@5559     ] as const) {5660       expect(getMessageContext(type)).toBe("extension-page-only");5761     }62+  });63+64+  it("covers every message type against popup, content-script, and foreign senders", () => {65+    const popup = {66+      id: "peach-extension",67+      url: "chrome-extension://peach-extension/src/popup/index.html",68+    };69+    const contentScript = { id: "peach-extension", url: "https://evil.test/login" };70+    const foreign = { id: "other-extension", url: "https://evil.test/login" };71+72+    for (const [type, context] of Object.entries(MESSAGE_CONTEXT_POLICY)) {73+      expect(74+        isMessageSenderAllowed(type, popup, "peach-extension", "chrome-extension://peach-extension/"),75+        `popup ${type}`,76+      ).toBe(context === "extension-page-only" || context === "content-script-allowed");77+      expect(78+        isMessageSenderAllowed(type, contentScript, "peach-extension", "chrome-extension://peach-extension/"),79+        `content script ${type}`,80+      ).toBe(context === "content-script-allowed" || context === "page-relay");81+      expect(82+        isMessageSenderAllowed(type, foreign, "peach-extension", "chrome-extension://peach-extension/"),83+        `foreign sender ${type}`,84+      ).toBe(false);85+    }86+87+    expect(88+      isMessageSenderAllowed(89+        "TOTALLY_UNKNOWN_MESSAGE",90+        contentScript,91+        "peach-extension",92+        "chrome-extension://peach-extension/",93+      ),94+    ).toBe(false);5895   });5996 });

view file

src/background/message-policy.ts+37 −0
@@ -6,6 +6,11 @@66   | "background-internal"77   | "page-relay";88 9+export interface MessageSenderShape {10+  id?: string;11+  url?: string;12+}13+914 /**1015  * Every message type has an explicit trust boundary. Keep this record1116  * exhaustive: adding a MessageType without choosing its sender context must@@ -145,4 +150,36 @@145150   return Object.hasOwn(MESSAGE_CONTEXT_POLICY, type)146151     ? MESSAGE_CONTEXT_POLICY[type as MessageType]147152     : undefined;153+}154+155+export function isMessageSenderAllowed(156+  type: unknown,157+  sender: MessageSenderShape,158+  extensionId: string | undefined,159+  extensionOrigin: string,160+): boolean {161+  if (sender.id !== undefined && sender.id !== extensionId) return false;162+163+  const context = getMessageContext(type);164+  if (context === "background-internal" || context === undefined) return false;165+166+  if (context === "extension-page-only") {167+    if (!sender.url) return true;168+    try {169+      return new URL(sender.url).origin === new URL(extensionOrigin).origin;170+    } catch {171+      return false;172+    }173+  }174+175+  if (context === "page-relay") {176+    if (!sender.url) return false;177+    try {178+      return new URL(sender.url).origin !== new URL(extensionOrigin).origin;179+    } catch {180+      return false;181+    }182+  }183+184+  return true;148185 }

view file

src/background/messages.adversarial.test.ts+20 −0
@@ -685,6 +685,14 @@685685       "RESTORE_VAULT_SNAPSHOT",686686       "RESTORE_FROM_CODEX",687687     ] as const;688+    const SENSITIVE = [689+      "GET_ENTRIES",690+      "GET_RECOVERY_WORDS",691+      "RESTORE_ENTRIES",692+      "UNPAIR_DEVICE",693+      "CONFIRM_VOUCHED_DEVICE",694+      "ROTATE_SYNC_IDENTITY",695+    ] as const;688696     const contentScript = {689697       id: "peach-extension",690698       url: "https://evil.test/",@@ -705,6 +713,18 @@705713         await setupUnlockedVault();706714         const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);707715         // It may still fail downstream validation, but never on the origin gate.716+        expect(r.error).not.toBe("invalid_sender");717+      });718+    }719+720+    for (const type of SENSITIVE) {721+      it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {722+        const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);723+        expect(r).toEqual({ ok: false, error: "invalid_sender" });724+      });725+726+      it(`${type} from an extension-page sender reaches the handler`, async () => {727+        const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);708728         expect(r.error).not.toBe("invalid_sender");709729       });710730     }

view file

src/background/messages.ts+7 −36
@@ -105,7 +105,7 @@105105   getSyncState,106106   triggerSyncIfReachable,107107 } from "./sync-trigger";108-import { getMessageContext } from "./message-policy";108+import { isMessageSenderAllowed } from "./message-policy";109109 110110 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in111111 // every production build — verified in the bundled output). Mirrors the@@ -207,39 +207,6 @@207207   return undefined;208208 }209209 210-function isExtensionPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {211-  // A missing URL is the sender shape used by extension UI in unit tests and212-  // by some browser APIs. A real content-script sender has a browser-attested213-  // page URL, so it cannot use this compatibility path.214-  if (!sender.url) return true;215-  try {216-    return new URL(sender.url).origin === new URL(chrome.runtime.getURL("")).origin;217-  } catch {218-    return false;219-  }220-}221-222-function isPageSender(sender: Partial<chrome.runtime.MessageSender>): boolean {223-  return Boolean(sender.url) && !isExtensionPageSender(sender);224-}225-226-function isSenderAllowedForContext(227-  context: ReturnType<typeof getMessageContext>,228-  sender: Partial<chrome.runtime.MessageSender>,229-): boolean {230-  switch (context) {231-    case "extension-page-only":232-      return isExtensionPageSender(sender);233-    case "content-script-allowed":234-      return true;235-    case "page-relay":236-      return isPageSender(sender);237-    case "background-internal":238-    case undefined:239-      return false;240-  }241-}242-243210 // Resolves once restoreFromSession() has run, so handlers see the correct244211 // post-restore unlock state. entry.ts registers this BEFORE the message245212 // handler so the onMessage listener is added synchronously at SW startup@@ -277,8 +244,12 @@277244     // PB-26-016: sender authorization is an exhaustive policy, not a278245     // hand-maintained privileged denylist. A missing/unknown policy entry or a279246     // background-only message fails closed before any handler runs.280-    const messageContext = getMessageContext(msg.type);281-    if (!isSenderAllowedForContext(messageContext, sender)) {247+    if (!isMessageSenderAllowed(248+      msg.type,249+      sender,250+      chrome.runtime.id,251+      chrome.runtime.getURL(""),252+    )) {282253       return { ok: false, error: "invalid_sender" };283254     }284255 

view file

src/background/storage-access.test.ts+27 −0
@@ -0,0 +1,27 @@1+import { describe, expect, it, vi } from "vitest";2+3+import { restrictLocalStorageToTrustedContexts } from "./storage-access";4+5+describe("local storage access hardening", () => {6+  it("restricts local storage to trusted extension contexts when supported", async () => {7+    const setAccessLevel = vi.fn(async () => undefined);8+    vi.stubGlobal("chrome", { storage: { local: { setAccessLevel } } });9+10+    await restrictLocalStorageToTrustedContexts();11+12+    expect(setAccessLevel).toHaveBeenCalledWith({ accessLevel: "TRUSTED_CONTEXTS" });13+  });14+15+  it("does not fail startup when Firefox lacks setAccessLevel", async () => {16+    vi.stubGlobal("chrome", { storage: { local: {} } });17+18+    await expect(restrictLocalStorageToTrustedContexts()).resolves.toBeUndefined();19+  });20+21+  it("does not fail startup when the browser rejects the optional hardening call", async () => {22+    const setAccessLevel = vi.fn(async () => { throw new Error("unsupported"); });23+    vi.stubGlobal("chrome", { storage: { local: { setAccessLevel } } });24+25+    await expect(restrictLocalStorageToTrustedContexts()).resolves.toBeUndefined();26+  });27+});

view file

src/background/storage-access.ts+18 −0
@@ -0,0 +1,18 @@1+/**2+ * Restrict persistent extension storage from content scripts when the browser3+ * exposes the WebExtensions access-level API. Firefox versions without this4+ * method remain safe for the sensitive paths because message authorization and5+ * session-owned secrets do not rely on this defense-in-depth setting.6+ */7+export async function restrictLocalStorageToTrustedContexts(): Promise<void> {8+  const local = chrome.storage?.local as (typeof chrome.storage.local & {9+    setAccessLevel?: (options: { accessLevel: "TRUSTED_CONTEXTS" }) => Promise<void>;10+  }) | undefined;11+  if (typeof local?.setAccessLevel !== "function") return;12+  try {13+    await local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });14+  } catch {15+    // Optional browser hardening must never prevent the extension from16+    // starting; the message policy remains the primary security boundary.17+  }18+}

view file

src/background/vault.rotate-sync-identity.test.ts+16 −7
@@ -4,7 +4,7 @@44 // a lost/stolen device — it swaps in a new root key so the compromised device's55 // retained key material becomes useless. It also:66 //   - Clears all paired devices (they must re-pair)7-//   - Sets peach_recovery_pending (new recovery words must be acknowledged)7+//   - Sets session-owned recovery-pending state (new recovery words must be acknowledged)88 //   - Rewraps the new root key under the SAME password-derived key (so the99 //     vault stays unlockable with the current password)1010 //   - Does NOT re-encrypt existing entries (they are password-sealed, not@@ -28,6 +28,7 @@2828   chrome: typeof globalThis.chrome;2929 } {3030   const storage: StorageShape = {};31+  const sessionStorage: StorageShape = {};3132   const chrome = {3233     storage: {3334       local: {@@ -49,9 +50,15 @@4950         }),5051       },5152       session: {52-        get: vi.fn(async () => ({})),53-        set: vi.fn(async () => {}),54-        remove: vi.fn(async () => {}),53+        get: vi.fn(async (key?: string | string[]) => {54+          if (!key) return { ...sessionStorage };55+          if (typeof key === "string") return { [key]: sessionStorage[key] };56+          return key.reduce<StorageShape>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});57+        }),58+        set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59+        remove: vi.fn(async (keys: string | string[]) => {60+          for (const key of (Array.isArray(keys) ? keys : [keys])) delete sessionStorage[key];61+        }),5562       },5663     },5764     alarms: {@@ -147,7 +154,7 @@147154     expect(isEmpty).toBe(true);148155   });149156 150-  // T-016d: peach_recovery_pending is set to "1" after rotation.157+  // T-016d: the session-owned recovery-pending state is set after rotation.151158   // Rotation generates new 12 recovery words (derived from the new root key).152159   // The user MUST re-confirm these words before the old ones become the only153160   // backup. The recovery_pending flag routes the popup back to the word display.@@ -155,10 +162,12 @@155162   // Bug this catches: forgetting setRecoveryConfirmPending() after rotation156163   // means the user is never prompted to re-confirm new recovery words — they157164   // continue thinking their old words are valid when they are not.158-  it("T-016d: peach_recovery_pending set to '1' after rotation", async () => {165+  it("T-016d: session recovery-pending state is set after rotation", async () => {159166     await vault.rotateSyncIdentity();160167 161-    expect(storage["peach_recovery_pending"]).toBe("1");168+    const { isRecoveryConfirmPending } = await import("./recovery-pending");169+    await expect(isRecoveryConfirmPending()).resolves.toBe(true);170+    expect(storage["peach_recovery_pending"]).toBeUndefined();162171   });163172 164173   // T-016e: vault is still unlockable with the same password after rotation.

view file