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