1234567891011121314import { beforeEach, describe, expect, it, vi } from "vitest";1516import type { Message, MessageResponse } from "../shared/types";1718type StorageShape = Record<string, unknown>;19type MessageListener = (20 msg: unknown,21 sender: chrome.runtime.MessageSender,22 sendResponse: (response: MessageResponse) => void23) => boolean;242526const PAYLOAD_HANDLERS = [27 "UNLOCK",28 "CREATE_VAULT",29 "ADD_ENTRY",30 "UPDATE_ENTRY",31 "DELETE_ENTRY",32 "BATCH_ADD_ENTRIES",33 "RESTORE_ENTRIES",34 "ADD_ADDITIONAL_URL",35 "RESTORE_ENTRY",36 "PURGE_ENTRY",37 "PURGE_EXPIRED_TRASH",38 "SAVE_NOTE",39 "DELETE_NOTE",40 "RECOVER_WITH_WORDS",41 "VALIDATE_RECOVERY_WORDS",42 "RESTORE_FROM_CODEX",43 "AUTOFILL_QUERY",44 "GET_ENTRY_FOR_FILL",45 "COPY",46 "UPDATE_SETTINGS",47 "APPROVE_PASSKEY",48 "DENY_PASSKEY",49 "SET_PASSKEY_PROVIDER_ENABLED",50 "ACTIVATE_LICENSE",51 "RESTORE_VAULT_SNAPSHOT",52 "CHANGE_PASSWORD",53 "SETUP_DURESS",54 "VERIFY_MAIN_PASSWORD",55 "FIRE_CANARY_PING",56 "ML_PREDICT_BATCH",57 "SEARCH_VAULT",58 "GET_PASSKEY_FOR_DOMAIN",59];6061describe("message handler adversarial inputs", () => {62 let storage: StorageShape;63 let sessionStorage: StorageShape;64 let listener: MessageListener | undefined;6566 beforeEach(async () => {67 vi.resetModules();68 vi.useRealTimers();69 storage = { peach_settings: { breachMonitoring: false } };70 sessionStorage = {};71 listener = undefined;7273 vi.stubGlobal(74 "fetch",75 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))76 );77 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);7879 const chromeMock = {80 runtime: {81 id: "peach-extension",82 getURL: (p: string) => `chrome-extension://peach-extension/${p}`,83 onMessage: {84 addListener: vi.fn((fn: MessageListener) => {85 listener = fn;86 }),87 },88 sendMessage: vi.fn(async () => ({ ok: true })),89 },90 tabs: {91 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {92 if (typeof cb === "function") {93 cb([{ id: 1 }]);94 return;95 }96 return Promise.resolve([{ id: 1 }]);97 }),98 sendMessage: vi.fn(async () => ({ ok: true })),99 },100 storage: {101 local: {102 get: vi.fn(async (key?: string | string[]) => {103 if (!key) return storage;104 if (typeof key === "string") return { [key]: storage[key] };105 return key.reduce<Record<string, unknown>>((acc, k) => {106 acc[k] = storage[k];107 return acc;108 }, {});109 }),110 set: vi.fn(async (next: Record<string, unknown>) => {111 Object.assign(storage, next);112 }),113 remove: vi.fn(async (keys: string | string[]) => {114 const list = Array.isArray(keys) ? keys : [keys];115 for (const key of list) delete storage[key];116 }),117 },118 session: {119 get: vi.fn(async (key?: string | string[]) => {120 if (!key) return sessionStorage;121 if (typeof key === "string") return { [key]: sessionStorage[key] };122 return key.reduce<Record<string, unknown>>((acc, k) => {123 acc[k] = sessionStorage[k];124 return acc;125 }, {});126 }),127 set: vi.fn(async (next: Record<string, unknown>) => {128 Object.assign(sessionStorage, next);129 }),130 remove: vi.fn(async (keys: string | string[]) => {131 const list = Array.isArray(keys) ? keys : [keys];132 for (const key of list) delete sessionStorage[key];133 }),134 clear: vi.fn(async () => {135 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];136 }),137 },138 },139 alarms: {140 clear: vi.fn(),141 create: vi.fn(),142 onAlarm: { addListener: vi.fn() },143 },144 idle: {145 setDetectionInterval: vi.fn(),146 onStateChanged: { addListener: vi.fn() },147 },148 scripting: { executeScript: vi.fn(async () => []) },149 } as unknown as typeof chrome;150151 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;152153 const messages = await import("./messages");154 messages.setupMessageHandler();155 const vault = await import("./vault");156 await vault.ensureSettingsLoaded();157 });158159 async function dispatch(160 message: Message,161 sender: chrome.runtime.MessageSender = {162 id: "peach-extension",163 url: "chrome-extension://peach-extension/popup.html",164 } as chrome.runtime.MessageSender,165 ): Promise<MessageResponse> {166 if (!listener) throw new Error("listener not registered");167 return new Promise<MessageResponse>((resolve) => {168 const keepOpen = listener!(message, sender, resolve);169 expect(keepOpen).toBe(true);170 });171 }172173 174 175 const EXTENSION_PAGE_SENDER = {176 id: "peach-extension",177 url: "chrome-extension://peach-extension/popup.html",178 } as chrome.runtime.MessageSender;179180 async function setupUnlockedVault() {181 const vault = await import("./vault");182 await vault.createVault("test-pass-12345");183 }184185 186187 describe("payload no-crash matrix", () => {188 for (const type of PAYLOAD_HANDLERS) {189 it(`${type} with null payload does not crash`, async () => {190 const r = await dispatch({ type, payload: null } as unknown as Message);191 expect(r).toBeDefined();192 });193194 it(`${type} with undefined payload does not crash`, async () => {195 const r = await dispatch({196 type,197 payload: undefined,198 } as unknown as Message);199 expect(r).toBeDefined();200 });201202 it(`${type} with empty object payload does not crash`, async () => {203 const r = await dispatch({ type, payload: {} } as unknown as Message);204 expect(r).toBeDefined();205 });206207 it(`${type} with deeply nested payload does not crash`, async () => {208 const nested: Record<string, unknown> = {};209 let cur: Record<string, unknown> = nested;210 for (let i = 0; i < 50; i++) {211 const child: Record<string, unknown> = {};212 cur.child = child;213 cur = child;214 }215 const r = await dispatch({216 type,217 payload: nested,218 } as unknown as Message);219 expect(r).toBeDefined();220 });221222 it(`${type} with array payload does not crash`, async () => {223 const r = await dispatch({224 type,225 payload: [1, 2, 3],226 } as unknown as Message);227 expect(r).toBeDefined();228 });229230 it(`${type} with string payload does not crash`, async () => {231 const r = await dispatch({232 type,233 payload: "garbage",234 } as unknown as Message);235 expect(r).toBeDefined();236 });237238 it(`${type} with number payload does not crash`, async () => {239 const r = await dispatch({240 type,241 payload: 42,242 } as unknown as Message);243 expect(r).toBeDefined();244 });245 }246 });247248 249 250 251 252 253254 describe("AUD-007 extended payload no-crash sweep", () => {255 const extraPayloads: Array<{ name: string; payload: unknown }> = [256 { name: "boolean true", payload: true },257 { name: "boolean false", payload: false },258 { name: "negative number", payload: -1 },259 { name: "NaN", payload: NaN },260 { name: "Infinity", payload: Infinity },261 { name: "empty string", payload: "" },262 { name: "very long string", payload: "A".repeat(10_000) },263 { name: "unicode string", payload: "私のパスワード🔑" },264 { name: "RTL string", payload: "مفتاح" },265 { name: "null bytes in string", payload: "a\x00b\x00c" },266 { name: "html string", payload: "<script>alert(1)</script>" },267 { name: "sql string", payload: "'; DROP TABLE entries; --" },268 { name: "path traversal string", payload: "../../etc/passwd" },269 { name: "object with null prototype-ish", payload: { __proto__: null } },270 { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },271 { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },272 { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },273 { name: "long array", payload: new Array(1_000).fill(0) },274 { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },275 { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },276 { name: "Date object", payload: new Date(0) },277 { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },278 { name: "circular-safe placeholder", payload: { self: "[Circular]" } },279 ];280281 for (const { name, payload } of extraPayloads) {282 for (const type of PAYLOAD_HANDLERS) {283 it(`${type} with ${name} payload does not crash`, async () => {284 const r = await dispatch({ type, payload } as unknown as Message);285 expect(r).toBeDefined();286 expect(typeof r.ok).toBe("boolean");287 });288 }289 }290 });291292 293294 describe("unknown message types", () => {295 const unknownTypes = [296 "TOTALLY_UNKNOWN_TYPE",297 "",298 "X",299 "lowercase_type",300 "with spaces in type",301 "with-dashes",302 "WITH.DOTS",303 "🌸emoji_type",304 "Unicode_üñïcödé",305 "IMPORT_PEACHSCRIPT",306 "RESTORE_FROM_PEACHSCRIPT",307 "DESTROY_VAULT_QUIETLY",308 "ESCALATE_PRIVILEGE",309 ];310311 for (const type of unknownTypes) {312 it(`unknown type "${type}" returns error response`, async () => {313 const r = await dispatch({ type } as unknown as Message);314 expect(r).toBeDefined();315 expect(r.ok).toBe(false);316 });317 }318319 it("missing type field does not crash", async () => {320 const r = await dispatch({} as unknown as Message);321 expect(r).toBeDefined();322 });323324 it("type=null does not crash", async () => {325 const r = await dispatch({ type: null } as unknown as Message);326 expect(r).toBeDefined();327 });328329 it("type=number does not crash", async () => {330 const r = await dispatch({ type: 42 } as unknown as Message);331 expect(r).toBeDefined();332 });333 });334335 336337 describe("sender validation", () => {338 it("foreign extension sender is rejected", async () => {339 if (!listener) throw new Error("listener not registered");340 const r = await new Promise<MessageResponse>((resolve) => {341 listener!(342 { type: "GET_ENTRIES" } as Message,343 { id: "foreign-extension" } as chrome.runtime.MessageSender,344 resolve345 );346 });347 expect(r.ok).toBe(false);348 expect(r.error).toBe("invalid_sender");349 });350351 it("sender with no URL is rejected", async () => {352 if (!listener) throw new Error("listener not registered");353 await setupUnlockedVault();354 const r = await new Promise<MessageResponse>((resolve) => {355 listener!(356 { type: "GET_ENTRIES" } as Message,357 {} as chrome.runtime.MessageSender,358 resolve359 );360 });361 expect(r).toMatchObject({ ok: false, error: "invalid_sender" });362 });363 });364365 366367 describe("AUTOFILL_QUERY adversarial", () => {368 async function withEntries(n: number) {369 const vault = await import("./vault");370 await vault.createVault("test-pass-12345");371 for (let i = 0; i < n; i++) {372 await vault.addEntry({373 title: `E${i}`,374 username: `u${i}`,375 password: "p",376 url: `https://example${i}.com`,377 type: "login",378 });379 }380 }381382 it("empty domain returns empty results", async () => {383 await withEntries(3);384 const r = await dispatch({385 type: "AUTOFILL_QUERY",386 payload: { domain: "" },387 });388 expect(r.ok).toBe(true);389 expect((r as { data: unknown[] }).data).toEqual([]);390 });391392 const adversarialDomains = [393 "*",394 ".*",395 "%",396 "<script>alert(1)</script>",397 "' OR '1'='1",398 "../../../etc/passwd",399 "\x00github.com",400 "github.com\x00",401 "GITHUB.COM",402 "github.com/path",403 "https://github.com",404 "javascript:alert(1)",405 " ",406 " ",407 "\t",408 "\n",409 String("a").repeat(5000),410 ];411 for (const domain of adversarialDomains) {412 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {413 await withEntries(3);414 const r = await dispatch({415 type: "AUTOFILL_QUERY",416 payload: { domain },417 });418 expect(r.ok).toBe(true);419 });420421 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {422 await withEntries(5);423 const r = await dispatch({424 type: "AUTOFILL_QUERY",425 payload: { domain },426 });427 const data = (r as { data: unknown[] }).data;428 429 expect(data.length).toBeLessThan(5);430 });431 }432433 it("locked vault: AUTOFILL_QUERY returns error", async () => {434 const vault = await import("./vault");435 await vault.createVault("test-pass-12345");436 vault.lock();437 const r = await dispatch({438 type: "AUTOFILL_QUERY",439 payload: { domain: "github.com" },440 });441 expect(r.ok).toBe(false);442 });443444 it("response never leaks privateKeyJwk", async () => {445 await withEntries(3);446 const r = await dispatch({447 type: "AUTOFILL_QUERY",448 payload: { domain: "example0.com" },449 });450 const data = (r as { data: Array<Record<string, unknown>> }).data;451 for (const entry of data) {452 expect("privateKeyJwk" in entry).toBe(false);453 expect("passkey" in entry).toBe(false);454 }455 });456457 it("kind=card returns card summaries only (no full PAN)", async () => {458 const vault = await import("./vault");459 await vault.createVault("test-pass-12345");460 await vault.addEntry({461 type: "card",462 title: "Visa",463 cardNumber: "4111111111111111",464 last4: "1111",465 cardholderName: "JUNE",466 expMonth: "12",467 expYear: "2030",468 cvc: "123",469 } as unknown as Parameters<typeof vault.addEntry>[0]);470 const r = await dispatch({471 type: "AUTOFILL_QUERY",472 payload: { domain: "any.com", kind: "card" },473 });474 const data = (r as { data: Array<Record<string, unknown>> }).data;475 for (const entry of data) {476 expect("cardNumber" in entry).toBe(false);477 expect("cvc" in entry).toBe(false);478 }479 });480 });481482 483484 describe("RESTORE_FROM_CODEX adversarial", () => {485 const malformed = [486 { name: "empty vaultBlob", overrides: { vaultBlob: "" } },487 { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },488 { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },489 { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },490 { name: "negative timestamp", overrides: { timestamp: -1 } },491 { name: "zero timestamp", overrides: { timestamp: 0 } },492 { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },493 { name: "negative entryCount", overrides: { entryCount: -5 } },494 ];495 for (const { name, overrides } of malformed) {496 it(`malformed payload (${name}) returns ok=false without crash`, async () => {497 const payload = {498 vaultBlob: "ZGF0YQ==",499 timestamp: Date.now(),500 verifyCode: "PX-TEST-1234",501 vaultFingerprint: "ABCDEF1234567890",502 entryCount: 0,503 masterPassphrase: "secret",504 ...overrides,505 };506 const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);507 expect(r).toBeDefined();508 expect(r.ok).toBe(false);509 });510 }511512 it("null payload does not crash", async () => {513 const r = await dispatch({514 type: "RESTORE_FROM_CODEX",515 payload: null,516 } as unknown as Message, EXTENSION_PAGE_SENDER);517 expect(r).toBeDefined();518 expect(r.ok).toBe(false);519 });520 });521522 523524 describe("COPY handler adversarial", () => {525 for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {526 it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {527 const r = await dispatch({528 type: "COPY",529 payload: { text, label: "test", alreadyWritten: true },530 });531 expect(r).toBeDefined();532 });533 }534 });535536 537538 describe("FIRE_CANARY_PING webhook url validation", () => {539 const badUrls = [540 "javascript:alert(1)",541 "file:///etc/passwd",542 "data:text/plain,evil",543 "",544 "not a url",545 "ftp://example.com/hook",546 ];547 for (const url of badUrls) {548 it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {549 const r = await dispatch({550 type: "FIRE_CANARY_PING",551 payload: {552 webhookUrl: url,553 entryTitle: "x",554 trigger: "test",555 },556 });557 expect(r.ok).toBe(false);558 });559 }560 });561562 563564 describe("GET_ENTRY_FOR_FILL safety", () => {565 it("returns entry without passkey field for login entry", async () => {566 const vault = await import("./vault");567 await vault.createVault("test-pass-12345");568 const e = await vault.addEntry({569 title: "GitHub",570 username: "june",571 password: "x",572 url: "https://github.com",573 type: "login",574 });575 const r = await dispatch(576 { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },577 { id: "peach-extension", url: "https://github.com/login" },578 );579 expect(r.ok).toBe(true);580 const data = (r as { data: Record<string, unknown> }).data;581 expect("passkey" in data).toBe(false);582 expect("privateKeyJwk" in data).toBe(false);583 });584585 it("missing id returns error", async () => {586 await setupUnlockedVault();587 const r = await dispatch({588 type: "GET_ENTRY_FOR_FILL",589 payload: { id: "not-a-real-id" },590 });591 expect(r.ok).toBe(false);592 });593594 it("locked vault returns error", async () => {595 const vault = await import("./vault");596 await vault.createVault("test-pass-12345");597 vault.lock();598 const r = await dispatch({599 type: "GET_ENTRY_FOR_FILL",600 payload: { id: "x" },601 });602 expect(r.ok).toBe(false);603 });604 });605606 607608 describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {609 async function dispatchFrom(610 message: Message,611 sender: chrome.runtime.MessageSender,612 ): Promise<MessageResponse> {613 if (!listener) throw new Error("listener not registered");614 return new Promise<MessageResponse>((resolve) => {615 listener!(message, sender, resolve);616 });617 }618619 async function addGithubEntry() {620 const vault = await import("./vault");621 await vault.createVault("test-pass-12345");622 return vault.addEntry({623 title: "GitHub",624 username: "june",625 password: "x",626 url: "https://github.com",627 type: "login",628 });629 }630631 it("PB-26-201: tab-context GET_ENTRY_FOR_EDIT is denied", async () => {632 const e = await addGithubEntry();633 const r = await dispatchFrom(634 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,635 { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,636 );637 expect(r.ok).toBe(false);638 expect(r.error).toBe("invalid_sender");639 });640641 it("PB-26-201: foreign tab cannot query edit data", async () => {642 const e = await addGithubEntry();643 const r = await dispatchFrom(644 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,645 { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,646 );647 expect(r.ok).toBe(false);648 expect(r.error).toBe("invalid_sender");649 expect((r as { data?: unknown }).data).toBeUndefined();650 });651652 it("PB-26-201: explicitSummon cannot authorize edit data", async () => {653 const e = await addGithubEntry();654 const r = await dispatchFrom(655 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,656 { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,657 );658 expect(r.ok).toBe(false);659 expect(r.error).toBe("invalid_sender");660 });661662 it("PB-26-201: extension popup remains authorized for edit data", async () => {663 const e = await addGithubEntry();664 const r = await dispatchFrom(665 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,666 { id: "peach-extension", url: "chrome-extension://peach-extension/src/popup/index.html" } as chrome.runtime.MessageSender,667 );668 expect(r.ok).toBe(true);669 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");670 });671 });672673 674675 describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {676 async function dispatchFrom(677 message: Message,678 sender: chrome.runtime.MessageSender,679 ): Promise<MessageResponse> {680 if (!listener) throw new Error("listener not registered");681 return new Promise<MessageResponse>((resolve) => {682 listener!(message, sender, resolve);683 });684 }685 const DESTRUCTIVE = [686 "DESTROY_VAULT",687 "CLEAR_VAULT_ENTRIES",688 "RESTORE_VAULT_SNAPSHOT",689 "RESTORE_FROM_CODEX",690 ] as const;691 const SENSITIVE = [692 "GET_ENTRIES",693 "GET_RECOVERY_WORDS",694 "RESTORE_ENTRIES",695 "UNPAIR_DEVICE",696 "CONFIRM_VOUCHED_DEVICE",697 "ROTATE_SYNC_IDENTITY",698 ] as const;699 const contentScript = {700 id: "peach-extension",701 url: "https://evil.test/",702 } as chrome.runtime.MessageSender;703 const extensionPage = {704 id: "peach-extension",705 url: "chrome-extension://peach-extension/popup.html",706 } as chrome.runtime.MessageSender;707708 for (const type of DESTRUCTIVE) {709 it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {710 await setupUnlockedVault();711 const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);712 expect(r).toEqual({ ok: false, error: "invalid_sender" });713 });714715 it(`${type} from an extension-page sender passes the origin gate`, async () => {716 await setupUnlockedVault();717 const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);718 719 expect(r.error).not.toBe("invalid_sender");720 });721 }722723 for (const type of SENSITIVE) {724 it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {725 const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);726 expect(r).toEqual({ ok: false, error: "invalid_sender" });727 });728729 it(`${type} from an extension-page sender reaches the handler`, async () => {730 const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);731 expect(r.error).not.toBe("invalid_sender");732 });733 }734735 it("GET_LOCAL_IDENTITY from a content-script sender is rejected (audit 2026-08-20 #4)", async () => {736 const r = await dispatchFrom({ type: "GET_LOCAL_IDENTITY", payload: {} } as Message, contentScript);737 expect(r).toEqual({ ok: false, error: "invalid_sender" });738 });739 });740741 742743 describe("ADD_ENTRY locked-state safety", () => {744 it("locked vault: ADD_ENTRY returns vault locked", async () => {745 const r = await dispatch({746 type: "ADD_ENTRY",747 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },748 });749 expect(r.ok).toBe(false);750 });751752 it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {753 await setupUnlockedVault();754 const r = await dispatch({755 type: "ADD_ENTRY",756 payload: { title: "x", username: "u", password: "", url: "https://e.com" },757 });758 expect(r.ok).toBe(true);759 });760 });761762 763764 describe("DELETE_ENTRY adversarial", () => {765 const badIds = [766 "",767 " ",768 "not-a-uuid",769 String("a").repeat(10000),770 "00000000-0000-0000-0000-000000000000",771 "<script>",772 "\x00",773 ];774 for (const id of badIds) {775 it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {776 await setupUnlockedVault();777 const r = await dispatch({778 type: "DELETE_ENTRY",779 payload: { id },780 });781 expect(r.ok).toBe(false);782 });783 }784 });785786 787788 describe("BATCH_ADD_ENTRIES adversarial", () => {789 it("empty array does not crash", async () => {790 await setupUnlockedVault();791 const r = await dispatch({792 type: "BATCH_ADD_ENTRIES",793 payload: { entries: [] },794 });795 expect(r).toBeDefined();796 });797798 it("non-array entries does not crash", async () => {799 await setupUnlockedVault();800 const r = await dispatch({801 type: "BATCH_ADD_ENTRIES",802 payload: { entries: "not-an-array" as unknown as unknown[] },803 });804 expect(r).toBeDefined();805 });806807 it("mostly-malformed entries does not crash", async () => {808 await setupUnlockedVault();809 const r = await dispatch({810 type: "BATCH_ADD_ENTRIES",811 payload: {812 entries: [813 null,814 undefined,815 42,816 "string",817 { title: "ok", username: "u", password: "p", url: "https://e.com" },818 ] as unknown as unknown[],819 },820 });821 expect(r).toBeDefined();822 });823 });824825 826827 describe("SEARCH_VAULT adversarial", () => {828 const queries = [829 "",830 " ",831 "x",832 "a".repeat(1000),833 "🔑",834 "<script>",835 "' OR '1'='1",836 "../../",837 "\x00",838 ];839 for (const query of queries) {840 it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {841 await setupUnlockedVault();842 const r = await dispatch({843 type: "SEARCH_VAULT",844 payload: { query },845 });846 expect(r).toBeDefined();847 });848 }849 });850851 852853 describe("UPDATE_SETTINGS adversarial", () => {854 const patches = [855 { name: "empty", patch: {} },856 { name: "junk keys", patch: { __proto__: { polluted: true } } },857 { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },858 { name: "negative autolock", patch: { autoLockMinutes: -5 } },859 { name: "string autolock", patch: { autoLockMinutes: "five" } },860 { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },861 ];862 for (const { name, patch } of patches) {863 it(`patch ${name} does not crash`, async () => {864 await setupUnlockedVault();865 const r = await dispatch({866 type: "UPDATE_SETTINGS",867 payload: patch as Record<string, unknown>,868 });869 expect(r).toBeDefined();870 });871 }872873 it("prototype pollution check: Object.prototype.polluted stays unset", async () => {874 await setupUnlockedVault();875 await dispatch({876 type: "UPDATE_SETTINGS",877 payload: JSON.parse(878 '{"__proto__": {"polluted": "yes"}}'879 ) as Record<string, unknown>,880 });881 882 expect(({} as any).polluted).toBeUndefined();883 });884 });885886 887888 describe("wide adversarial payload matrix", () => {889 const adversarialPayloads: Array<{ name: string; payload: unknown }> = [890 { name: "true", payload: true },891 { name: "false", payload: false },892 { name: "NaN", payload: NaN },893 { name: "Infinity", payload: Infinity },894 { name: "-Infinity", payload: -Infinity },895 { name: "BigInt-like", payload: { type: "bigint" } },896 { name: "Date", payload: new Date() },897 { name: "RegExp", payload: /pattern/ },898 { name: "Symbol-string", payload: Symbol("x").toString() },899 { name: "circular reference", payload: (() => {900 const o: Record<string, unknown> = {};901 o.self = o;902 return o;903 })() },904 ];905906 const fastHandlers = [907 "ADD_ENTRY",908 "UPDATE_ENTRY",909 "DELETE_ENTRY",910 "AUTOFILL_QUERY",911 "GET_ENTRY_FOR_FILL",912 "UPDATE_SETTINGS",913 "SEARCH_VAULT",914 "SAVE_NOTE",915 "DELETE_NOTE",916 "PURGE_ENTRY",917 "RESTORE_ENTRY",918 "PURGE_EXPIRED_TRASH",919 "ADD_ADDITIONAL_URL",920 "BATCH_ADD_ENTRIES",921 "RESTORE_ENTRIES",922 "ACTIVATE_LICENSE",923 "APPROVE_PASSKEY",924 "DENY_PASSKEY",925 "SET_PASSKEY_PROVIDER_ENABLED",926 "RESTORE_VAULT_SNAPSHOT",927 "VERIFY_MAIN_PASSWORD",928 "SETUP_DURESS",929 ];930931 for (const handler of fastHandlers) {932 for (const { name, payload } of adversarialPayloads) {933 it(`${handler} + ${name} payload resolves without crash`, async () => {934 const r = await dispatch({ type: handler, payload } as unknown as Message);935 expect(r).toBeDefined();936 });937 }938 }939 });940941 942943 describe("foreign sender rejection matrix", () => {944 const safeQuickHandlers = [945 "GET_ENTRIES",946 "IS_UNLOCKED",947 "HAS_VAULT",948 "GET_VAULT_STATUS",949 "GET_SETTINGS",950 "GET_DELETED_ENTRIES",951 "COUNT_DUPLICATES",952 "GET_VAULT_HISTORY",953 "ADD_ENTRY",954 "DELETE_ENTRY",955 "AUTOFILL_QUERY",956 "GET_ENTRY_FOR_FILL",957 "SAVE_NOTE",958 "DELETE_NOTE",959 "GET_NOTES",960 "SEARCH_VAULT",961 "GET_LICENSE",962 "GET_LICENSE_KEY",963 "GET_RECOVERY_WORDS",964 "VALIDATE_RECOVERY_WORDS",965 "RECOVER_WITH_WORDS",966 "RESTORE_FROM_CODEX",967 "GET_CODEX_VAULT_DATA",968 "ESTIMATE_CODEX_PAGES",969 ];970 for (const handler of safeQuickHandlers) {971 it(`${handler} from foreign sender returns invalid sender`, async () => {972 if (!listener) throw new Error("listener not registered");973 const r = await new Promise<MessageResponse>((resolve) => {974 listener!(975 { type: handler, payload: {} } as Message,976 { id: "foreign-extension-id" } as chrome.runtime.MessageSender,977 resolve978 );979 });980 expect(r.ok).toBe(false);981 expect(r.error).toBe("invalid_sender");982 });983 }984 });985986 987988 describe("ADD_ENTRY payload shape robustness", () => {989 const shapes = [990 { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },991 { name: "no url", body: { title: "t", username: "u", password: "p" } },992 { name: "title only", body: { title: "t", password: "p" } },993 { name: "spurious type", body: { type: "login", title: "t", password: "p" } },994 { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },995 { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },996 { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },997 { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },998 { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },999 { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },1000 { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },1001 { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },1002 ];1003 for (const { name, body } of shapes) {1004 it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {1005 await setupUnlockedVault();1006 const r = await dispatch({1007 type: "ADD_ENTRY",1008 payload: body as Parameters<typeof dispatch>[0]["payload"],1009 } as Message);1010 expect(r).toBeDefined();1011 });1012 }1013 });10141015 10161017 describe("UPDATE_ENTRY patch shape robustness", () => {1018 const patches = [1019 { name: "title only", patch: { title: "New" } },1020 { name: "password only", patch: { password: "newpass" } },1021 { name: "url only", patch: { url: "https://e2.com" } },1022 { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },1023 { name: "unknown field", patch: { totallyMadeUp: "x" } },1024 { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },1025 { name: "createdAt forbidden", patch: { createdAt: 0 } },1026 { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },1027 { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },1028 ];1029 for (const { name, patch } of patches) {1030 it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {1031 const vault = await import("./vault");1032 await vault.createVault("test-pass-12345");1033 const e = await vault.addEntry({1034 title: "x",1035 username: "u",1036 password: "p",1037 url: "https://e.com",1038 type: "login",1039 });1040 const r = await dispatch({1041 type: "UPDATE_ENTRY",1042 payload: { id: e.id, patch },1043 });1044 expect(r).toBeDefined();1045 });1046 }1047 });10481049 10501051 describe("vault lifecycle adversarial sequences", () => {1052 for (let i = 0; i < 20; i++) {1053 it(`random message sequence #${i} does not crash`, async () => {1054 const vault = await import("./vault");1055 await vault.createVault("test-pass-12345");1056 const handlers = [1057 { type: "GET_ENTRIES" },1058 { type: "IS_UNLOCKED" },1059 { type: "GET_VAULT_STATUS" },1060 { type: "HAS_VAULT" },1061 { type: "GET_DELETED_ENTRIES" },1062 { type: "GET_SETTINGS" },1063 { type: "COUNT_DUPLICATES" },1064 { type: "GET_VAULT_HISTORY" },1065 ] as Array<Message>;1066 for (const h of handlers) {1067 const r = await dispatch(h);1068 expect(r).toBeDefined();1069 }1070 });1071 }1072 });10731074 10751076 describe("read-only handlers when locked", () => {1077 const safeWhenLocked = [1078 "IS_UNLOCKED",1079 "HAS_VAULT",1080 "GET_VAULT_STATUS",1081 "GET_SETTINGS",1082 ];1083 for (const type of safeWhenLocked) {1084 it(`${type} works without crash when locked`, async () => {1085 const r = await dispatch({ type } as unknown as Message);1086 expect(r).toBeDefined();1087 });1088 }10891090 const gatedWhenLocked = [1091 "GET_ENTRIES",1092 "GET_DELETED_ENTRIES",1093 "COUNT_DUPLICATES",1094 "GET_IDENTITIES",1095 "GET_IDENTITY_SUGGESTIONS",1096 ];1097 for (const type of gatedWhenLocked) {1098 it(`${type} returns ok=false when locked`, async () => {1099 const r = await dispatch({ type } as unknown as Message);1100 expect(r).toBeDefined();1101 1102 if (r.ok === true) {1103 1104 const data = (r as { data: unknown }).data;1105 expect(1106 data == null ||1107 (Array.isArray(data) && data.length === 0) ||1108 data === 01109 ).toBe(true);1110 } else {1111 expect(r.ok).toBe(false);1112 }1113 });1114 }1115 });11161117 11181119 describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1120 1121 1122 1123 1124 const cases: Array<{ type: string; bad: unknown[] }> = [1125 {1126 type: "ADD_ENTRY",1127 bad: [null, "string", 42, [], true],1128 },1129 {1130 type: "BATCH_ADD_ENTRIES",1131 bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1132 },1133 {1134 type: "UPDATE_ENTRY",1135 bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1136 },1137 {1138 type: "RESTORE_ENTRIES",1139 bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1140 },1141 {1142 type: "UPDATE_SETTINGS",1143 bad: [null, "string", 42, []],1144 },1145 ];11461147 for (const { type, bad } of cases) {1148 for (const payload of bad) {1149 const label = JSON.stringify(payload);1150 it(`${type} rejects malformed payload: ${label}`, async () => {1151 await setupUnlockedVault();1152 const r = await dispatch({ type, payload } as unknown as Message);1153 expect(r.ok).toBe(false);1154 const err = (r as { error?: string }).error ?? "";1155 expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1156 });1157 }1158 }1159 });11601161 11621163 describe("AUD-002 payload validation", () => {1164 describe("SEARCH_VAULT", () => {1165 const badQueries: Array<[string, unknown]> = [1166 ["null", null],1167 ["numeric", 42],1168 ["boolean", true],1169 ["object", { evil: true }],1170 ["array", []],1171 ["missing", undefined],1172 ];1173 for (const [name, query] of badQueries) {1174 it(`rejects ${name} query`, async () => {1175 await setupUnlockedVault();1176 const r = await dispatch({1177 type: "SEARCH_VAULT",1178 payload: { query },1179 } as unknown as Message);1180 expect(r.ok).toBe(false);1181 expect((r as { error?: string }).error).toBe("invalid_payload");1182 });1183 }11841185 it("accepts valid string query", async () => {1186 await setupUnlockedVault();1187 const r = await dispatch({1188 type: "SEARCH_VAULT",1189 payload: { query: "github" },1190 });1191 expect(r.ok).toBe(true);1192 });1193 });11941195 describe("COPY", () => {1196 const bads: Array<[string, unknown, unknown]> = [1197 ["null text", null, "pw"],1198 ["numeric text", 42, "pw"],1199 ["object text", { evil: true }, "pw"],1200 ["missing text", undefined, "pw"],1201 ["null label", "secret", null],1202 ["numeric label", "secret", 42],1203 ];1204 for (const [name, text, label] of bads) {1205 it(`rejects ${name}`, async () => {1206 await setupUnlockedVault();1207 const r = await dispatch({1208 type: "COPY",1209 payload: { text, label },1210 } as unknown as Message);1211 expect(r.ok).toBe(false);1212 expect((r as { error?: string }).error).toBe("invalid_payload");1213 });1214 }1215 });12161217 describe("ML_PREDICT_BATCH", () => {1218 const bads: Array<[string, unknown]> = [1219 ["null", null],1220 ["object", { not: "array" }],1221 ["string", "features"],1222 ["number", 42],1223 ["missing", undefined],1224 ];1225 for (const [name, featuresArray] of bads) {1226 it(`rejects ${name} featuresArray`, async () => {1227 const r = await dispatch({1228 type: "ML_PREDICT_BATCH",1229 payload: { featuresArray },1230 } as unknown as Message);1231 expect(r.ok).toBe(false);1232 expect((r as { error?: string }).error).toBe("invalid_payload");1233 });1234 }12351236 it("accepts empty array featuresArray", async () => {1237 const r = await dispatch({1238 type: "ML_PREDICT_BATCH",1239 payload: { featuresArray: [] },1240 } as unknown as Message);1241 expect((r as { error?: string }).error).not.toBe("invalid_payload");1242 });1243 });1244 });1245});