5c4a71d3e83b

fix: SEC-001/THREAT-001 whitelist Firefox passkey relay (v2.45.1)

time
2026-05-15 20:51 UTC · 5 months ago
machine
koh2
parent
53eceac206fc
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/passkeys-firefox-relay.test.ts+105 −0src/content/passkeys-firefox-relay.ts+22 −6
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.45.0",4+  "version": "2.45.1",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.45.0",4+  "version": "2.45.1",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.45.0",3+  "version": "2.45.1",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/passkeys-firefox-relay.test.ts+105 −0
@@ -0,0 +1,105 @@1+// @vitest-environment jsdom2+3+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";4+5+const mockSendMessage = vi.fn().mockResolvedValue({ ok: true });6+7+vi.stubGlobal("chrome", {8+  runtime: {9+    id: "ext-id",10+    sendMessage: mockSendMessage,11+  },12+});13+14+beforeAll(async () => {15+  await import("./passkeys-firefox-relay");16+});17+18+async function fire(payload: unknown, source: Window | null = window) {19+  window.dispatchEvent(20+    new MessageEvent("message", {21+      data: { type: "PEACH_PASSKEY_REQUEST", id: "x", payload },22+      source,23+    })24+  );25+  await new Promise((resolve) => setTimeout(resolve, 10));26+}27+28+describe("Firefox passkey relay allowlist", () => {29+  beforeEach(() => {30+    mockSendMessage.mockClear();31+  });32+33+  const allowed = [34+    "PASSKEY_CREATE",35+    "PASSKEY_GET",36+    "PASSKEY_HAS_CREDENTIALS",37+    "INITIATE_FIREFOX_PASSKEY_CREATE",38+    "COMPLETE_FIREFOX_PASSKEY_CREATE",39+    "PASSKEY_OFFER_DECLINE",40+    "GET_PASSKEY_OFFER_DECLINED",41+    "GET_PASSKEY_FOR_DOMAIN",42+    "PASSKEY_AUTH_RESPONSE",43+  ];44+45+  const blocked = [46+    "GET_ENTRIES",47+    "GET_RECOVERY_WORDS",48+    "DESTROY_VAULT",49+    "GET_DERIVED_KEY",50+    "ADD_ENTRY",51+    "UPDATE_ENTRY",52+    "DELETE_ENTRY",53+    "CLEAR_VAULT_ENTRIES",54+    "RESTORE_FROM_CODEX",55+    "CHANGE_PASSWORD",56+    "RECOVER_WITH_WORDS",57+    "SETUP_DURESS",58+    "PAIRING_COMPLETED",59+    "ACTIVATE_LICENSE",60+    "TRIGGER_SYNC",61+    "GET_IDENTITIES",62+    "SEARCH_VAULT",63+    "GET_ENTRY_FOR_FILL",64+    "TEST_LOCAL_WEBSOCKET",65+    "TEST_WS_LOCAL",66+    "",67+    "TOTALLY_MADE_UP",68+  ];69+70+  for (const type of allowed) {71+    it(`forwards allowed message: ${type}`, async () => {72+      await fire({ type });73+      expect(mockSendMessage).toHaveBeenCalledWith({ type });74+    });75+  }76+77+  for (const type of blocked) {78+    it(`blocks message: ${type || "(empty)"}`, async () => {79+      await fire({ type });80+      expect(mockSendMessage).not.toHaveBeenCalled();81+    });82+  }83+84+  it("blocks null payload", async () => {85+    await fire(null);86+    expect(mockSendMessage).not.toHaveBeenCalled();87+  });88+89+  it("blocks empty payload object", async () => {90+    await fire({});91+    expect(mockSendMessage).not.toHaveBeenCalled();92+  });93+94+  it("blocks cross-frame messages", async () => {95+    await fire({ type: "PASSKEY_GET" }, null);96+    expect(mockSendMessage).not.toHaveBeenCalled();97+  });98+99+  it("does not post a response for blocked messages", async () => {100+    const spy = vi.spyOn(window, "postMessage");101+    await fire({ type: "GET_ENTRIES" });102+    expect(spy).not.toHaveBeenCalled();103+    spy.mockRestore();104+  });105+});

view file

src/content/passkeys-firefox-relay.ts+22 −6
@@ -9,16 +9,25 @@99  * with the same request id.1010  *1111  * Trust boundary: anything the page can synthesize on `window.message` lands12- * here. The `type` and `id` fields gate dispatch; the `payload` is forwarded13- * verbatim to the background, which validates required fields and runs the14- * Pro/vault checks before performing any cryptographic work. Vault data is15- * never sent the other direction over postMessage — only the credential16- * shape the RP needs.12+ * here. Only the message types in ALLOWED_RELAY_TYPES are allowed through to13+ * the background. Vault/admin messages must never be proxied from page JS.1714  *1815  * Removal: deleted alongside the MAIN-world proxy when Firefox ships1916  * chrome.webAuthenticationProxy. See src/shared/passkey-flags.ts.2017  */2118 19+const ALLOWED_RELAY_TYPES = new Set([20+  "PASSKEY_CREATE",21+  "PASSKEY_GET",22+  "PASSKEY_HAS_CREDENTIALS",23+  "INITIATE_FIREFOX_PASSKEY_CREATE",24+  "COMPLETE_FIREFOX_PASSKEY_CREATE",25+  "PASSKEY_OFFER_DECLINE",26+  "GET_PASSKEY_OFFER_DECLINED",27+  "GET_PASSKEY_FOR_DOMAIN",28+  "PASSKEY_AUTH_RESPONSE",29+]);30+2231 window.addEventListener("message", async (event: MessageEvent) => {2332   if (event.source !== window) return;2433   const data = event.data as@@ -26,7 +35,12 @@2635     | undefined;2736   if (!data || data.type !== "PEACH_PASSKEY_REQUEST") return;2837   const { id, payload } = data;29-  if (typeof id !== "string" || !payload || typeof payload !== "object") return;38+  if (!payload || typeof payload !== "object") return;39+  const payloadType = (payload as { type?: unknown }).type;40+  if (typeof payloadType !== "string" || !ALLOWED_RELAY_TYPES.has(payloadType)) {41+    return;42+  }43+  if (typeof id !== "string") return;3044 3145   const reply = (replyPayload: unknown) => {3246     window.postMessage(@@ -45,3 +59,5 @@4559     reply({ ok: false, error: "extension_error" });4660   }4761 });62+63+export {};

view file