vault.update-entry-sanitize.test.ts

7.4 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).4//5// DESTINATION: src/background/vault.update-entry-sanitize.test.ts6// Imports are written in destination form; this file is excluded from the7// suite while it lives under src/drafts/.89import { beforeEach, describe, expect, it, vi } from "vitest";10import type { PasskeyData, VaultEntry } from "../shared/types";1112type StorageShape = Record<string, unknown>;1314describe("PCH-02-001 — updateEntry patch sanitizers", () => {15  let storage: StorageShape;1617  beforeEach(() => {18    vi.resetModules();19    storage = {};2021    const chromeMock = {22      storage: {23        local: {24          get: vi.fn(async (key?: string | string[]) => {25            if (!key) return storage;26            if (typeof key === "string") {27              return { [key]: storage[key] };28            }29            return key.reduce<Record<string, unknown>>((result, entry) => {30              result[entry] = storage[entry];31              return result;32            }, {});33          }),34          set: vi.fn(async (next: Record<string, unknown>) => {35            Object.assign(storage, next);36          }),37          remove: vi.fn(async (keys: string | string[]) => {38            const list = Array.isArray(keys) ? keys : [keys];39            for (const key of list) delete storage[key];40          }),41        },42      },43      alarms: {44        clear: vi.fn(),45        create: vi.fn(),46        onAlarm: { addListener: vi.fn() },47      },48      idle: {49        setDetectionInterval: vi.fn(),50        onStateChanged: { addListener: vi.fn() },51      },52    } as unknown as typeof chrome;5354    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5556    // Keep the fire-and-forget breach check off the network.57    vi.stubGlobal("fetch", vi.fn(async () => {58      throw new Error("network disabled in tests");59    }));60  });6162  async function vaultWithEntry(extra: Partial<VaultEntry> = {}) {63    const vault = await import("./vault");64    await vault.createVault("correct horse battery staple");65    const entry = await vault.addEntry({66      title: "Example",67      username: "june",68      password: "secret-1",69      url: "https://login.example.com",70      ...extra,71    } as Parameters<typeof vault.addEntry>[0]);72    return { vault, entry };73  }7475  const validPasskey: PasskeyData = {76    rpId: "example.com",77    rpName: "Example",78    credentialId: "Y3JlZC0x",79    publicKeyCose: "cGstY29zZQ",80    userHandle: "dXNlci0x",81    userName: "june",82    userDisplayName: "June",83    algorithm: -7,84    signCount: 0,85    aaguid: "",86    backupEligible: false,87    backupState: false,88    createdAt: 1_700_000_000_000,89  };9091  it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {92    const { vault, entry } = await vaultWithEntry();9394    await vault.updateEntry(entry.id, {95      canary: { webhookUrl: "http://attacker.example.com/exfil" },96    });9798    const updated = vault.getEntries().find((e) => e.id === entry.id);99    expect(updated?.canary).toBeUndefined();100  });101102  it("preserves a valid https canary (and its bump fields) through a patch", async () => {103    const { vault, entry } = await vaultWithEntry();104105    // Shape the content-script canary-ping bump sends (content/index.ts:336-348).106    await vault.updateEntry(entry.id, {107      canary: {108        webhookUrl: "https://legit.example.com/hook",109        label: "desk drawer",110        lastPinged: 1_700_000_111_000,111        pingCount: 3,112      },113    });114115    const updated = vault.getEntries().find((e) => e.id === entry.id);116    expect(updated?.canary).toEqual({117      webhookUrl: "https://legit.example.com/hook",118      label: "desk drawer",119      lastPinged: 1_700_000_111_000,120      pingCount: 3,121    });122  });123124  it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {125    const { vault, entry } = await vaultWithEntry();126127    await vault.updateEntry(entry.id, {128      canary: {129        webhookUrl: "https://legit.example.com/hook",130        exfil: "smuggled",131        __proto__: { polluted: true },132      } as unknown as VaultEntry["canary"],133    });134135    const updated = vault.getEntries().find((e) => e.id === entry.id);136    expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");137    // sanitizeCanary rebuilds the object — only the four whitelisted keys exist.138    const keys = Object.keys(updated!.canary!);139    expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);140    expect((updated!.canary as Record<string, unknown>).exfil).toBeUndefined();141    expect((updated!.canary as Record<string, unknown>).polluted).toBeUndefined();142  });143144  it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {145    const { vault, entry } = await vaultWithEntry();146147    await expect(148      vault.updateEntry(entry.id, {149        passkey: {150          rpId: "example.com",151          // credentialId missing — must not replace/attach a credential152          publicKeyCose: "cGstY29zZQ",153          userHandle: "dXNlci0x",154          userName: "june",155        } as unknown as PasskeyData,156      }),157    ).rejects.toThrow("invalid passkey credential");158159    const updated = vault.getEntries().find((e) => e.id === entry.id);160    expect(updated?.passkey).toBeUndefined();161    expect(updated?.password).toBe("secret-1");162  });163164  it("accepts a complete passkey patch (field-by-field rebuild)", async () => {165    const { vault, entry } = await vaultWithEntry();166167    await vault.updateEntry(entry.id, { passkey: validPasskey });168169    const updated = vault.getEntries().find((e) => e.id === entry.id);170    expect(updated?.passkey?.rpId).toBe("example.com");171    expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");172    expect(updated?.passkey?.algorithm).toBe(-7);173  });174175  it("still removes a passkey via `passkey: undefined`", async () => {176    const { vault, entry } = await vaultWithEntry();177    await vault.updateEntry(entry.id, { passkey: validPasskey });178179    await vault.updateEntry(entry.id, { passkey: undefined });180181    const updated = vault.getEntries().find((e) => e.id === entry.id);182    expect(updated?.passkey).toBeUndefined();183  });184185  it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {186    const { vault, entry } = await vaultWithEntry();187    await vault.updateEntry(entry.id, { tags: ["keep-me"] });188189    await vault.updateEntry(entry.id, {190      tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],191    });192193    const updated = vault.getEntries().find((e) => e.id === entry.id);194    expect(updated?.tags).toBeUndefined();195    expect(({} as Record<string, unknown>).polluted).toBeUndefined();196  });197198  it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {199    const { vault, entry } = await vaultWithEntry();200201    const oversized = "x".repeat(500);202    const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);203    await vault.updateEntry(entry.id, {204      tags: [oversized, 42, null, "  ", ...many] as unknown as string[],205    });206207    const updated = vault.getEntries().find((e) => e.id === entry.id);208    expect(updated?.tags).toBeDefined();209    expect(updated!.tags!.length).toBeLessThanOrEqual(255);210    expect(updated!.tags![0]).toBe("x".repeat(256));211    expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);212  });213});