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
2 files src/background/vault.ts+12 −3src/background/vault.update-entry-sanitize.test.ts+208 −0
src/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) {

view file

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+});

view file