messages.batch-sanitize.test.ts

7.1 KB · raw

1// T-008: PCH-01-016 batch-add canary/tag sanitization2//3// Why this test exists: the MV-6 import whitelist was applied to the4// RESTORE_ENTRIES path (vault.restoreEntries) but the BATCH_ADD_ENTRIES5// path was a separate code path that also called sanitizeEntryDraft.6// PCH-01-016 added sanitizeCanary and sanitizeTags to sanitizeEntryDraft7// so both entry-add paths apply the same sanitization. This is the actual8// attacker-reachable bulk surface — a content-script XSS or a rogue extension9// API caller could POST to BATCH_ADD_ENTRIES with a poisoned canary webhook10// URL or unbounded tags. No test previously exercised this path.1112import { beforeEach, describe, expect, it, vi } from "vitest";1314type StorageShape = Record<string, unknown>;1516describe("T-008: PCH-01-016 batch-add canary/tag sanitization", () => {17  let storage: StorageShape;18  let vault: typeof import("./vault");1920  beforeEach(async () => {21    vi.resetModules();22    storage = {};2324    const chromeMock = {25      storage: {26        local: {27          get: vi.fn(async (key?: string | string[]) => {28            if (!key) return storage;29            if (typeof key === "string") return { [key]: storage[key] };30            return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(31              (acc, k) => { acc[k] = storage[k]; return acc; },32              {},33            );34          }),35          set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),36          remove: vi.fn(async (keys: string | string[]) => {37            const ks = Array.isArray(keys) ? keys : [keys];38            for (const k of ks) delete storage[k];39          }),40        },41        session: {42          get: vi.fn(async () => ({})),43          set: vi.fn(async () => {}),44          remove: vi.fn(async () => {}),45        },46      },47      alarms: {48        clear: vi.fn(),49        create: vi.fn(),50        onAlarm: { addListener: vi.fn() },51      },52      idle: {53        setDetectionInterval: vi.fn(),54        onStateChanged: { addListener: vi.fn() },55      },56    } as unknown as typeof chrome;5758    (globalThis as Record<string, unknown>).chrome = chromeMock;59    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));6061    vault = await import("./vault");62    await vault.createVault("test-batch-sanitize-pass");63  });6465  // T-008a: non-HTTPS canary webhook URL is stripped from the entry.66  // The entry itself must be saved (canary stripped, not entry rejected).67  // Bug this catches: a poisoned canary could exfiltrate entry titles to an68  // attacker-controlled endpoint; HTTP (non-TLS) allows plaintext interception.69  it("T-008a: non-HTTPS canary webhook stripped (entry saved, canary absent)", async () => {70    const added = await vault.batchAddEntries([71      {72        type: "login",73        title: "Canary Test",74        username: "u",75        password: "p",76        url: "https://example.com",77        canary: { webhookUrl: "http://attacker.example.com/steal" },78      },79    ]);8081    expect(added).toHaveLength(1);82    expect(added[0].title).toBe("Canary Test");83    // Bug this catches: non-HTTPS canary passes through and gets stored84    expect(added[0].canary).toBeUndefined();85  });8687  // T-008b: HTTPS canary webhook URL is preserved.88  // This is the "legitimate canary" control case — HTTPS webhooks must work.89  it("T-008b: HTTPS canary webhook URL is preserved", async () => {90    const added = await vault.batchAddEntries([91      {92        type: "login",93        title: "Legit Canary",94        username: "u",95        password: "p",96        url: "https://example.com",97        canary: { webhookUrl: "https://legit.example.com/notify" },98      },99    ]);100101    expect(added).toHaveLength(1);102    expect(added[0].canary?.webhookUrl).toBe("https://legit.example.com/notify");103  });104105  // T-008c: tags are capped at 255 items and each tag is capped at 256 chars.106  // Bug this catches: unbounded tags could consume storage quota or cause UI107  // rendering issues; server-side (sync) tags that exceed the codec cap could108  // break Codex encode/decode.109  it("T-008c: oversized tags array is capped at 255 items", async () => {110    const massiveTags = Array.from({ length: 500 }, (_, i) => `tag-${i}`);111    const added = await vault.batchAddEntries([112      {113        type: "login",114        title: "Tag Test",115        username: "u",116        password: "p",117        url: "https://example.com",118        tags: massiveTags,119      },120    ]);121122    expect(added).toHaveLength(1);123    expect(added[0].tags!.length).toBeLessThanOrEqual(255);124  });125126  it("T-008c: individual tag exceeding 256 chars is truncated to 256 chars", async () => {127    const longTag = "x".repeat(300);128    const added = await vault.batchAddEntries([129      {130        type: "login",131        title: "Long Tag Test",132        username: "u",133        password: "p",134        url: "https://example.com",135        tags: [longTag],136      },137    ]);138139    expect(added).toHaveLength(1);140    expect(added[0].tags![0].length).toBeLessThanOrEqual(256);141  });142143  // T-008d: __proto__ key in entry payload must not appear on the saved entry.144  // Bug this catches: prototype pollution via imported entries; normalizeEntry145  // whitelists only known fields so __proto__ should be dropped entirely.146  it("T-008d: __proto__ key in payload is absent from the saved entry", async () => {147    const maliciousDraft = {148      type: "login" as const,149      title: "Proto Test",150      username: "u",151      password: "p",152      url: "https://example.com",153      // TypeScript won't let us assign __proto__ directly, so cast154      ...({ __proto__: { poisoned: true } } as object),155    };156157    const added = await vault.batchAddEntries([maliciousDraft]);158    expect(added).toHaveLength(1);159    // Bug this catches: prototype pollution persists in the saved entry160    expect((added[0] as unknown as Record<string, unknown>).poisoned).toBeUndefined();161    expect(Object.getPrototypeOf(added[0])).toBe(Object.prototype);162  });163164  // T-008e: passkey entry with missing credentialId is rejected.165  // sanitizeEntryDraft validates the passkey sub-object; a passkey without a166  // credentialId cannot be used for authentication and must not silently import167  // as a broken credential.168  it("T-008e: passkey entry missing credentialId is rejected (null from sanitizeEntryDraft)", async () => {169    const added = await vault.batchAddEntries([170      {171        type: "login" as const,172        title: "Bad Passkey",173        username: "u",174        password: "",175        url: "https://example.com",176        passkey: {177          rpId: "example.com",178          rpName: "Example",179          credentialId: "", // empty — invalid180          publicKeyCose: "a401020326",181          privateKeyJwk: { kty: "EC", crv: "P-256" },182          userHandle: "uh",183          userName: "user",184          userDisplayName: "User",185          algorithm: -7 as const,186          signCount: 0,187          aaguid: "AAAAAAAAAAAAAAAAAAAAAA",188          backupEligible: false,189          backupState: false,190          createdAt: Date.now(),191        },192      },193    ]);194195    // The entry should be skipped (batchAddEntries silently drops invalid drafts)196    expect(added).toHaveLength(0);197  });198});