vault.update-entry-sanitize.test.ts

7.7 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("preserves TOTP parameters through an UPDATE_ENTRY patch", async () => {88    const { vault, entry } = await vaultWithEntry({89      type: "login",90      totp: "JBSWY3DPEHPK3PXP",91      totpAlgorithm: "SHA256",92      totpDigits: 8,93      totpPeriod: 60,94    });9596    await vault.updateEntry(entry.id, {97      title: "Updated",98      totpAlgorithm: "SHA512",99      totpDigits: 6,100      totpPeriod: 45,101    });102103    expect(vault.getEntries().find((e) => e.id === entry.id)).toMatchObject({104      title: "Updated",105      totp: "JBSWY3DPEHPK3PXP",106      totpAlgorithm: "SHA512",107      totpDigits: 6,108      totpPeriod: 45,109    });110  });111112  it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {113    const { vault, entry } = await vaultWithEntry();114115    await vault.updateEntry(entry.id, {116      canary: { webhookUrl: "http://attacker.example.com/exfil" },117    });118119    const updated = vault.getEntries().find((e) => e.id === entry.id);120    expect(updated?.canary).toBeUndefined();121  });122123  it("preserves a valid https canary (and its bump fields) through a patch", async () => {124    const { vault, entry } = await vaultWithEntry();125126    // Shape the content-script canary-ping bump sends (content/index.ts:336-348).127    await vault.updateEntry(entry.id, {128      canary: {129        webhookUrl: "https://legit.example.com/hook",130        label: "desk drawer",131        lastPinged: 1_700_000_111_000,132        pingCount: 3,133      },134    });135136    const updated = vault.getEntries().find((e) => e.id === entry.id);137    expect(updated?.canary).toEqual({138      webhookUrl: "https://legit.example.com/hook",139      label: "desk drawer",140      lastPinged: 1_700_000_111_000,141      pingCount: 3,142    });143  });144145  it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {146    const { vault, entry } = await vaultWithEntry();147148    await vault.updateEntry(entry.id, {149      canary: {150        webhookUrl: "https://legit.example.com/hook",151        exfil: "smuggled",152        __proto__: { polluted: true },153      } as unknown as VaultEntry["canary"],154    });155156    const updated = vault.getEntries().find((e) => e.id === entry.id);157    expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");158    const keys = Object.keys(updated!.canary!);159    expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);160    const canaryRecord = updated!.canary as unknown as Record<string, unknown>;161    expect(canaryRecord.exfil).toBeUndefined();162    expect(canaryRecord.polluted).toBeUndefined();163  });164165  it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {166    const { vault, entry } = await vaultWithEntry();167168    await expect(169      vault.updateEntry(entry.id, {170        passkey: {171          rpId: "example.com",172          publicKeyCose: "cGstY29zZQ",173          userHandle: "dXNlci0x",174          userName: "june",175        } as unknown as PasskeyData,176      }),177    ).rejects.toThrow("invalid passkey credential");178179    const updated = vault.getEntries().find((e) => e.id === entry.id);180    expect(updated?.passkey).toBeUndefined();181    expect(updated?.password).toBe("secret-1");182  });183184  it("accepts a complete passkey patch (field-by-field rebuild)", async () => {185    const { vault, entry } = await vaultWithEntry();186187    await vault.updateEntry(entry.id, { passkey: validPasskey });188189    const updated = vault.getEntries().find((e) => e.id === entry.id);190    expect(updated?.passkey?.rpId).toBe("example.com");191    expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");192    expect(updated?.passkey?.algorithm).toBe(-7);193  });194195  it("still removes a passkey via `passkey: undefined`", async () => {196    const { vault, entry } = await vaultWithEntry();197    await vault.updateEntry(entry.id, { passkey: validPasskey });198199    await vault.updateEntry(entry.id, { passkey: undefined });200201    const updated = vault.getEntries().find((e) => e.id === entry.id);202    expect(updated?.passkey).toBeUndefined();203  });204205  it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {206    const { vault, entry } = await vaultWithEntry();207    await vault.updateEntry(entry.id, { tags: ["keep-me"] });208209    await vault.updateEntry(entry.id, {210      tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],211    });212213    const updated = vault.getEntries().find((e) => e.id === entry.id);214    expect(updated?.tags).toBeUndefined();215    expect(({} as Record<string, unknown>).polluted).toBeUndefined();216  });217218  it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {219    const { vault, entry } = await vaultWithEntry();220221    const oversized = "x".repeat(500);222    const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);223    await vault.updateEntry(entry.id, {224      tags: [oversized, 42, null, "  ", ...many] as unknown as string[],225    });226227    const updated = vault.getEntries().find((e) => e.id === entry.id);228    expect(updated?.tags).toBeDefined();229    expect(updated!.tags!.length).toBeLessThanOrEqual(255);230    expect(updated!.tags![0]).toBe("x".repeat(256));231    expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);232  });233});