// T-008: PCH-01-016 batch-add canary/tag sanitization // // Why this test exists: the MV-6 import whitelist was applied to the // RESTORE_ENTRIES path (vault.restoreEntries) but the BATCH_ADD_ENTRIES // path was a separate code path that also called sanitizeEntryDraft. // PCH-01-016 added sanitizeCanary and sanitizeTags to sanitizeEntryDraft // so both entry-add paths apply the same sanitization. This is the actual // attacker-reachable bulk surface — a content-script XSS or a rogue extension // API caller could POST to BATCH_ADD_ENTRIES with a poisoned canary webhook // URL or unbounded tags. No test previously exercised this path. import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; describe("T-008: PCH-01-016 batch-add canary/tag sanitization", () => { let storage: StorageShape; let vault: typeof import("./vault"); beforeEach(async () => { vi.resetModules(); storage = {}; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") return { [key]: storage[key] }; return (Array.isArray(key) ? key : [key]).reduce( (acc, k) => { acc[k] = storage[k]; return acc; }, {}, ); }), set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const ks = Array.isArray(keys) ? keys : [keys]; for (const k of ks) delete storage[k]; }), }, session: { get: vi.fn(async () => ({})), set: vi.fn(async () => {}), remove: vi.fn(async () => {}), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as Record).chrome = chromeMock; vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); })); vault = await import("./vault"); await vault.createVault("test-batch-sanitize-pass"); }); // T-008a: non-HTTPS canary webhook URL is stripped from the entry. // The entry itself must be saved (canary stripped, not entry rejected). // Bug this catches: a poisoned canary could exfiltrate entry titles to an // attacker-controlled endpoint; HTTP (non-TLS) allows plaintext interception. it("T-008a: non-HTTPS canary webhook stripped (entry saved, canary absent)", async () => { const added = await vault.batchAddEntries([ { type: "login", title: "Canary Test", username: "u", password: "p", url: "https://example.com", canary: { webhookUrl: "http://attacker.example.com/steal" }, }, ]); expect(added).toHaveLength(1); expect(added[0].title).toBe("Canary Test"); // Bug this catches: non-HTTPS canary passes through and gets stored expect(added[0].canary).toBeUndefined(); }); // T-008b: HTTPS canary webhook URL is preserved. // This is the "legitimate canary" control case — HTTPS webhooks must work. it("T-008b: HTTPS canary webhook URL is preserved", async () => { const added = await vault.batchAddEntries([ { type: "login", title: "Legit Canary", username: "u", password: "p", url: "https://example.com", canary: { webhookUrl: "https://legit.example.com/notify" }, }, ]); expect(added).toHaveLength(1); expect(added[0].canary?.webhookUrl).toBe("https://legit.example.com/notify"); }); // T-008c: tags are capped at 255 items and each tag is capped at 256 chars. // Bug this catches: unbounded tags could consume storage quota or cause UI // rendering issues; server-side (sync) tags that exceed the codec cap could // break Codex encode/decode. it("T-008c: oversized tags array is capped at 255 items", async () => { const massiveTags = Array.from({ length: 500 }, (_, i) => `tag-${i}`); const added = await vault.batchAddEntries([ { type: "login", title: "Tag Test", username: "u", password: "p", url: "https://example.com", tags: massiveTags, }, ]); expect(added).toHaveLength(1); expect(added[0].tags!.length).toBeLessThanOrEqual(255); }); it("T-008c: individual tag exceeding 256 chars is truncated to 256 chars", async () => { const longTag = "x".repeat(300); const added = await vault.batchAddEntries([ { type: "login", title: "Long Tag Test", username: "u", password: "p", url: "https://example.com", tags: [longTag], }, ]); expect(added).toHaveLength(1); expect(added[0].tags![0].length).toBeLessThanOrEqual(256); }); // T-008d: __proto__ key in entry payload must not appear on the saved entry. // Bug this catches: prototype pollution via imported entries; normalizeEntry // whitelists only known fields so __proto__ should be dropped entirely. it("T-008d: __proto__ key in payload is absent from the saved entry", async () => { const maliciousDraft = { type: "login" as const, title: "Proto Test", username: "u", password: "p", url: "https://example.com", // TypeScript won't let us assign __proto__ directly, so cast ...({ __proto__: { poisoned: true } } as object), }; const added = await vault.batchAddEntries([maliciousDraft]); expect(added).toHaveLength(1); // Bug this catches: prototype pollution persists in the saved entry expect((added[0] as unknown as Record).poisoned).toBeUndefined(); expect(Object.getPrototypeOf(added[0])).toBe(Object.prototype); }); // T-008e: passkey entry with missing credentialId is rejected. // sanitizeEntryDraft validates the passkey sub-object; a passkey without a // credentialId cannot be used for authentication and must not silently import // as a broken credential. it("T-008e: passkey entry missing credentialId is rejected (null from sanitizeEntryDraft)", async () => { const added = await vault.batchAddEntries([ { type: "login" as const, title: "Bad Passkey", username: "u", password: "", url: "https://example.com", passkey: { rpId: "example.com", rpName: "Example", credentialId: "", // empty — invalid publicKeyCose: "a401020326", privateKeyJwk: { kty: "EC", crv: "P-256" }, userHandle: "uh", userName: "user", userDisplayName: "User", algorithm: -7 as const, signCount: 0, aaguid: "AAAAAAAAAAAAAAAAAAAAAA", backupEligible: false, backupState: false, createdAt: Date.now(), }, }, ]); // The entry should be skipped (batchAddEntries silently drops invalid drafts) expect(added).toHaveLength(0); }); });