vault.update-entry-sanitize.test.ts

7.1 KB · raw

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).45import { beforeEach, describe, expect, it, vi } from "vitest";6import type { PasskeyData, VaultEntry } from "../shared/types";78type StorageShape = Record<string, unknown>;910describe("PCH-02-001 — updateEntry patch sanitizers", () => {11  let storage: StorageShape;1213  beforeEach(() => {14    vi.resetModules();15    storage = {};1617    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;4950    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5152    // 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  });5758  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  }7071  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  };8687  it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {88    const { vault, entry } = await vaultWithEntry();8990    await vault.updateEntry(entry.id, {91      canary: { webhookUrl: "http://attacker.example.com/exfil" },92    });9394    const updated = vault.getEntries().find((e) => e.id === entry.id);95    expect(updated?.canary).toBeUndefined();96  });9798  it("preserves a valid https canary (and its bump fields) through a patch", async () => {99    const { vault, entry } = await vaultWithEntry();100101    // 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    });110111    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  });119120  it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {121    const { vault, entry } = await vaultWithEntry();122123    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    });130131    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  });139140  it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {141    const { vault, entry } = await vaultWithEntry();142143    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");153154    const updated = vault.getEntries().find((e) => e.id === entry.id);155    expect(updated?.passkey).toBeUndefined();156    expect(updated?.password).toBe("secret-1");157  });158159  it("accepts a complete passkey patch (field-by-field rebuild)", async () => {160    const { vault, entry } = await vaultWithEntry();161162    await vault.updateEntry(entry.id, { passkey: validPasskey });163164    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  });169170  it("still removes a passkey via `passkey: undefined`", async () => {171    const { vault, entry } = await vaultWithEntry();172    await vault.updateEntry(entry.id, { passkey: validPasskey });173174    await vault.updateEntry(entry.id, { passkey: undefined });175176    const updated = vault.getEntries().find((e) => e.id === entry.id);177    expect(updated?.passkey).toBeUndefined();178  });179180  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"] });183184    await vault.updateEntry(entry.id, {185      tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],186    });187188    const updated = vault.getEntries().find((e) => e.id === entry.id);189    expect(updated?.tags).toBeUndefined();190    expect(({} as Record<string, unknown>).polluted).toBeUndefined();191  });192193  it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {194    const { vault, entry } = await vaultWithEntry();195196    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    });201202    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});