16d51870a784
fix: PCH-02-001 UPDATE_ENTRY patch routes canary/tags/passkey through existing sanitizers (v4.0.0)
- time
- 2026-06-12 20:33 UTC · 4 months ago
- machine
- koh2
- agent
- gpt-5
- session
- pch-02 integration
- parent
- e69763aa6d3d
↓ peach-browser-16d51870.tar.gzatlas at this save
files changed2 files
src/background/vault.ts+12 −3src/background/vault.update-entry-sanitize.test.ts+208 −0src/background/vault.ts+12 −3
@@ -2373,18 +2373,27 @@23732373 }23742374 23752375 if ("canary" in patch) {2376- nextPatch.canary = patch.canary;2376+ // PCH-02-001: route update patches through the same canary whitelist used2377+ // by add/restore/sync. Invalid shapes drop the canary; `undefined` clears it.2378+ nextPatch.canary = patch.canary ? sanitizeCanary(patch.canary) : undefined;23772379 }23782380 23792381 if ("passkey" in patch) {23802382 // Passkey sub-object replaces all the legacy flat passkey fields. A23812383 // patch with `passkey: undefined` removes the credential from the entry23822384 // (used by "remove passkey" without deleting the whole entry).2383- nextPatch.passkey = patch.passkey;2385+ if (patch.passkey === undefined || patch.passkey === null) {2386+ nextPatch.passkey = undefined;2387+ } else {2388+ const sanitized = sanitizePasskey(patch.passkey);2389+ if (!sanitized) throw new Error("invalid passkey credential");2390+ nextPatch.passkey = sanitized;2391+ }23842392 }23852393 23862394 if ("tags" in patch) {2387- nextPatch.tags = patch.tags;2395+ // PCH-02-001: bound tags exactly like both import funnels.2396+ nextPatch.tags = sanitizeTags(patch.tags);23882397 }23892398 23902399 if ("cardholderName" in patch) {src/background/vault.update-entry-sanitize.test.ts+208 −0
@@ -0,0 +1,208 @@1+// PCH-02-001 regression tests — UPDATE_ENTRY patch path must route2+// canary/tags/passkey through the same sanitizers the add/restore/sync3+// funnels use (sanitizeCanary / sanitizePasskey / sanitizeTags).4+5+import { beforeEach, describe, expect, it, vi } from "vitest";6+import type { PasskeyData, VaultEntry } from "../shared/types";7+8+type StorageShape = Record<string, unknown>;9+10+describe("PCH-02-001 — updateEntry patch sanitizers", () => {11+ let storage: StorageShape;12+13+ beforeEach(() => {14+ vi.resetModules();15+ storage = {};16+17+ const chromeMock = {18+ storage: {19+ local: {20+ get: vi.fn(async (key?: string | string[]) => {21+ if (!key) return storage;22+ if (typeof key === "string") {23+ return { [key]: storage[key] };24+ }25+ return key.reduce<Record<string, unknown>>((result, entry) => {26+ result[entry] = storage[entry];27+ return result;28+ }, {});29+ }),30+ set: vi.fn(async (next: Record<string, unknown>) => {31+ Object.assign(storage, next);32+ }),33+ remove: vi.fn(async (keys: string | string[]) => {34+ const list = Array.isArray(keys) ? keys : [keys];35+ for (const key of list) delete storage[key];36+ }),37+ },38+ },39+ alarms: {40+ clear: vi.fn(),41+ create: vi.fn(),42+ onAlarm: { addListener: vi.fn() },43+ },44+ idle: {45+ setDetectionInterval: vi.fn(),46+ onStateChanged: { addListener: vi.fn() },47+ },48+ } as unknown as typeof chrome;49+50+ (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;51+52+ // Keep the fire-and-forget breach check off the network.53+ vi.stubGlobal("fetch", vi.fn(async () => {54+ throw new Error("network disabled in tests");55+ }));56+ });57+58+ async function vaultWithEntry(extra: Partial<VaultEntry> = {}) {59+ const vault = await import("./vault");60+ await vault.createVault("correct horse battery staple");61+ const entry = await vault.addEntry({62+ title: "Example",63+ username: "june",64+ password: "secret-1",65+ url: "https://login.example.com",66+ ...extra,67+ } as Parameters<typeof vault.addEntry>[0]);68+ return { vault, entry };69+ }70+71+ const validPasskey: PasskeyData = {72+ rpId: "example.com",73+ rpName: "Example",74+ credentialId: "Y3JlZC0x",75+ publicKeyCose: "cGstY29zZQ",76+ userHandle: "dXNlci0x",77+ userName: "june",78+ userDisplayName: "June",79+ algorithm: -7,80+ signCount: 0,81+ aaguid: "",82+ backupEligible: false,83+ backupState: false,84+ createdAt: 1_700_000_000_000,85+ };86+87+ it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {88+ const { vault, entry } = await vaultWithEntry();89+90+ await vault.updateEntry(entry.id, {91+ canary: { webhookUrl: "http://attacker.example.com/exfil" },92+ });93+94+ const updated = vault.getEntries().find((e) => e.id === entry.id);95+ expect(updated?.canary).toBeUndefined();96+ });97+98+ it("preserves a valid https canary (and its bump fields) through a patch", async () => {99+ const { vault, entry } = await vaultWithEntry();100+101+ // Shape the content-script canary-ping bump sends (content/index.ts:336-348).102+ await vault.updateEntry(entry.id, {103+ canary: {104+ webhookUrl: "https://legit.example.com/hook",105+ label: "desk drawer",106+ lastPinged: 1_700_000_111_000,107+ pingCount: 3,108+ },109+ });110+111+ const updated = vault.getEntries().find((e) => e.id === entry.id);112+ expect(updated?.canary).toEqual({113+ webhookUrl: "https://legit.example.com/hook",114+ label: "desk drawer",115+ lastPinged: 1_700_000_111_000,116+ pingCount: 3,117+ });118+ });119+120+ it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {121+ const { vault, entry } = await vaultWithEntry();122+123+ await vault.updateEntry(entry.id, {124+ canary: {125+ webhookUrl: "https://legit.example.com/hook",126+ exfil: "smuggled",127+ __proto__: { polluted: true },128+ } as unknown as VaultEntry["canary"],129+ });130+131+ const updated = vault.getEntries().find((e) => e.id === entry.id);132+ expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");133+ const keys = Object.keys(updated!.canary!);134+ expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);135+ const canaryRecord = updated!.canary as unknown as Record<string, unknown>;136+ expect(canaryRecord.exfil).toBeUndefined();137+ expect(canaryRecord.polluted).toBeUndefined();138+ });139+140+ it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {141+ const { vault, entry } = await vaultWithEntry();142+143+ await expect(144+ vault.updateEntry(entry.id, {145+ passkey: {146+ rpId: "example.com",147+ publicKeyCose: "cGstY29zZQ",148+ userHandle: "dXNlci0x",149+ userName: "june",150+ } as unknown as PasskeyData,151+ }),152+ ).rejects.toThrow("invalid passkey credential");153+154+ const updated = vault.getEntries().find((e) => e.id === entry.id);155+ expect(updated?.passkey).toBeUndefined();156+ expect(updated?.password).toBe("secret-1");157+ });158+159+ it("accepts a complete passkey patch (field-by-field rebuild)", async () => {160+ const { vault, entry } = await vaultWithEntry();161+162+ await vault.updateEntry(entry.id, { passkey: validPasskey });163+164+ const updated = vault.getEntries().find((e) => e.id === entry.id);165+ expect(updated?.passkey?.rpId).toBe("example.com");166+ expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");167+ expect(updated?.passkey?.algorithm).toBe(-7);168+ });169+170+ it("still removes a passkey via `passkey: undefined`", async () => {171+ const { vault, entry } = await vaultWithEntry();172+ await vault.updateEntry(entry.id, { passkey: validPasskey });173+174+ await vault.updateEntry(entry.id, { passkey: undefined });175+176+ const updated = vault.getEntries().find((e) => e.id === entry.id);177+ expect(updated?.passkey).toBeUndefined();178+ });179+180+ it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {181+ const { vault, entry } = await vaultWithEntry();182+ await vault.updateEntry(entry.id, { tags: ["keep-me"] });183+184+ await vault.updateEntry(entry.id, {185+ tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],186+ });187+188+ const updated = vault.getEntries().find((e) => e.id === entry.id);189+ expect(updated?.tags).toBeUndefined();190+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();191+ });192+193+ it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {194+ const { vault, entry } = await vaultWithEntry();195+196+ const oversized = "x".repeat(500);197+ const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);198+ await vault.updateEntry(entry.id, {199+ tags: [oversized, 42, null, " ", ...many] as unknown as string[],200+ });201+202+ const updated = vault.getEntries().find((e) => e.id === entry.id);203+ expect(updated?.tags).toBeDefined();204+ expect(updated!.tags!.length).toBeLessThanOrEqual(255);205+ expect(updated!.tags![0]).toBe("x".repeat(256));206+ expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);207+ });208+});