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
↓ peach-browser-34ea8d9a.tar.gzatlas at this save
files changed11 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 −7manifest.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": {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",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",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,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 });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 }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 }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 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+});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+}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.