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 onMessage: {83 addListener: vi.fn((fn: MessageListener) => {84 listener = fn;85 }),86 },87 sendMessage: vi.fn(async () => ({ ok: true })),88 },89 tabs: {90 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {91 if (typeof cb === "function") {92 cb([{ id: 1 }]);93 return;94 }95 return Promise.resolve([{ id: 1 }]);96 }),97 sendMessage: vi.fn(async () => ({ ok: true })),98 },99 storage: {100 local: {101 get: vi.fn(async (key?: string | string[]) => {102 if (!key) return storage;103 if (typeof key === "string") return { [key]: storage[key] };104 return key.reduce<Record<string, unknown>>((acc, k) => {105 acc[k] = storage[k];106 return acc;107 }, {});108 }),109 set: vi.fn(async (next: Record<string, unknown>) => {110 Object.assign(storage, next);111 }),112 remove: vi.fn(async (keys: string | string[]) => {113 const list = Array.isArray(keys) ? keys : [keys];114 for (const key of list) delete storage[key];115 }),116 },117 session: {118 get: vi.fn(async (key?: string | string[]) => {119 if (!key) return sessionStorage;120 if (typeof key === "string") return { [key]: sessionStorage[key] };121 return key.reduce<Record<string, unknown>>((acc, k) => {122 acc[k] = sessionStorage[k];123 return acc;124 }, {});125 }),126 set: vi.fn(async (next: Record<string, unknown>) => {127 Object.assign(sessionStorage, next);128 }),129 remove: vi.fn(async (keys: string | string[]) => {130 const list = Array.isArray(keys) ? keys : [keys];131 for (const key of list) delete sessionStorage[key];132 }),133 clear: vi.fn(async () => {134 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];135 }),136 },137 },138 alarms: {139 clear: vi.fn(),140 create: vi.fn(),141 onAlarm: { addListener: vi.fn() },142 },143 idle: {144 setDetectionInterval: vi.fn(),145 onStateChanged: { addListener: vi.fn() },146 },147 scripting: { executeScript: vi.fn(async () => []) },148 } as unknown as typeof chrome;149150 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;151152 const messages = await import("./messages");153 messages.setupMessageHandler();154 const vault = await import("./vault");155 await vault.ensureSettingsLoaded();156 });157158 async function dispatch(message: Message): Promise<MessageResponse> {159 if (!listener) throw new Error("listener not registered");160 return new Promise<MessageResponse>((resolve) => {161 const keepOpen = listener!(162 message,163 { id: "peach-extension" } as chrome.runtime.MessageSender,164 resolve165 );166 expect(keepOpen).toBe(true);167 });168 }169170 async function setupUnlockedVault() {171 const vault = await import("./vault");172 await vault.createVault("test-pass-12345");173 }174175 176177 describe("payload no-crash matrix", () => {178 for (const type of PAYLOAD_HANDLERS) {179 it(`${type} with null payload does not crash`, async () => {180 const r = await dispatch({ type, payload: null } as unknown as Message);181 expect(r).toBeDefined();182 });183184 it(`${type} with undefined payload does not crash`, async () => {185 const r = await dispatch({186 type,187 payload: undefined,188 } as unknown as Message);189 expect(r).toBeDefined();190 });191192 it(`${type} with empty object payload does not crash`, async () => {193 const r = await dispatch({ type, payload: {} } as unknown as Message);194 expect(r).toBeDefined();195 });196197 it(`${type} with deeply nested payload does not crash`, async () => {198 const nested: Record<string, unknown> = {};199 let cur: Record<string, unknown> = nested;200 for (let i = 0; i < 50; i++) {201 const child: Record<string, unknown> = {};202 cur.child = child;203 cur = child;204 }205 const r = await dispatch({206 type,207 payload: nested,208 } as unknown as Message);209 expect(r).toBeDefined();210 });211212 it(`${type} with array payload does not crash`, async () => {213 const r = await dispatch({214 type,215 payload: [1, 2, 3],216 } as unknown as Message);217 expect(r).toBeDefined();218 });219220 it(`${type} with string payload does not crash`, async () => {221 const r = await dispatch({222 type,223 payload: "garbage",224 } as unknown as Message);225 expect(r).toBeDefined();226 });227228 it(`${type} with number payload does not crash`, async () => {229 const r = await dispatch({230 type,231 payload: 42,232 } as unknown as Message);233 expect(r).toBeDefined();234 });235 }236 });237238 239 240 241 242 243244 describe("AUD-007 extended payload no-crash sweep", () => {245 const extraPayloads: Array<{ name: string; payload: unknown }> = [246 { name: "boolean true", payload: true },247 { name: "boolean false", payload: false },248 { name: "negative number", payload: -1 },249 { name: "NaN", payload: NaN },250 { name: "Infinity", payload: Infinity },251 { name: "empty string", payload: "" },252 { name: "very long string", payload: "A".repeat(10_000) },253 { name: "unicode string", payload: "私のパスワード🔑" },254 { name: "RTL string", payload: "مفتاح" },255 { name: "null bytes in string", payload: "a\x00b\x00c" },256 { name: "html string", payload: "<script>alert(1)</script>" },257 { name: "sql string", payload: "'; DROP TABLE entries; --" },258 { name: "path traversal string", payload: "../../etc/passwd" },259 { name: "object with null prototype-ish", payload: { __proto__: null } },260 { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },261 { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },262 { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },263 { name: "long array", payload: new Array(1_000).fill(0) },264 { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },265 { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },266 { name: "Date object", payload: new Date(0) },267 { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },268 { name: "circular-safe placeholder", payload: { self: "[Circular]" } },269 ];270271 for (const { name, payload } of extraPayloads) {272 for (const type of PAYLOAD_HANDLERS) {273 it(`${type} with ${name} payload does not crash`, async () => {274 const r = await dispatch({ type, payload } as unknown as Message);275 expect(r).toBeDefined();276 expect(typeof r.ok).toBe("boolean");277 });278 }279 }280 });281282 283284 describe("unknown message types", () => {285 const unknownTypes = [286 "TOTALLY_UNKNOWN_TYPE",287 "",288 "X",289 "lowercase_type",290 "with spaces in type",291 "with-dashes",292 "WITH.DOTS",293 "🌸emoji_type",294 "Unicode_üñïcödé",295 "IMPORT_PEACHSCRIPT",296 "RESTORE_FROM_PEACHSCRIPT",297 "DESTROY_VAULT_QUIETLY",298 "ESCALATE_PRIVILEGE",299 ];300301 for (const type of unknownTypes) {302 it(`unknown type "${type}" returns error response`, async () => {303 const r = await dispatch({ type } as unknown as Message);304 expect(r).toBeDefined();305 expect(r.ok).toBe(false);306 });307 }308309 it("missing type field does not crash", async () => {310 const r = await dispatch({} as unknown as Message);311 expect(r).toBeDefined();312 });313314 it("type=null does not crash", async () => {315 const r = await dispatch({ type: null } as unknown as Message);316 expect(r).toBeDefined();317 });318319 it("type=number does not crash", async () => {320 const r = await dispatch({ type: 42 } as unknown as Message);321 expect(r).toBeDefined();322 });323 });324325 326327 describe("sender validation", () => {328 it("foreign extension sender is rejected", async () => {329 if (!listener) throw new Error("listener not registered");330 const r = await new Promise<MessageResponse>((resolve) => {331 listener!(332 { type: "GET_ENTRIES" } as Message,333 { id: "foreign-extension" } as chrome.runtime.MessageSender,334 resolve335 );336 });337 expect(r.ok).toBe(false);338 expect(r.error).toBe("invalid_sender");339 });340341 it("sender with no id (extension UI) is accepted", async () => {342 if (!listener) throw new Error("listener not registered");343 await setupUnlockedVault();344 const r = await new Promise<MessageResponse>((resolve) => {345 listener!(346 { type: "GET_ENTRIES" } as Message,347 {} as chrome.runtime.MessageSender,348 resolve349 );350 });351 expect(r.ok).toBe(true);352 });353 });354355 356357 describe("AUTOFILL_QUERY adversarial", () => {358 async function withEntries(n: number) {359 const vault = await import("./vault");360 await vault.createVault("test-pass-12345");361 for (let i = 0; i < n; i++) {362 await vault.addEntry({363 title: `E${i}`,364 username: `u${i}`,365 password: "p",366 url: `https://example${i}.com`,367 type: "login",368 });369 }370 }371372 it("empty domain returns empty results", async () => {373 await withEntries(3);374 const r = await dispatch({375 type: "AUTOFILL_QUERY",376 payload: { domain: "" },377 });378 expect(r.ok).toBe(true);379 expect((r as { data: unknown[] }).data).toEqual([]);380 });381382 const adversarialDomains = [383 "*",384 ".*",385 "%",386 "<script>alert(1)</script>",387 "' OR '1'='1",388 "../../../etc/passwd",389 "\x00github.com",390 "github.com\x00",391 "GITHUB.COM",392 "github.com/path",393 "https://github.com",394 "javascript:alert(1)",395 " ",396 " ",397 "\t",398 "\n",399 String("a").repeat(5000),400 ];401 for (const domain of adversarialDomains) {402 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {403 await withEntries(3);404 const r = await dispatch({405 type: "AUTOFILL_QUERY",406 payload: { domain },407 });408 expect(r.ok).toBe(true);409 });410411 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {412 await withEntries(5);413 const r = await dispatch({414 type: "AUTOFILL_QUERY",415 payload: { domain },416 });417 const data = (r as { data: unknown[] }).data;418 419 expect(data.length).toBeLessThan(5);420 });421 }422423 it("locked vault: AUTOFILL_QUERY returns error", async () => {424 const vault = await import("./vault");425 await vault.createVault("test-pass-12345");426 vault.lock();427 const r = await dispatch({428 type: "AUTOFILL_QUERY",429 payload: { domain: "github.com" },430 });431 expect(r.ok).toBe(false);432 });433434 it("response never leaks privateKeyJwk", async () => {435 await withEntries(3);436 const r = await dispatch({437 type: "AUTOFILL_QUERY",438 payload: { domain: "example0.com" },439 });440 const data = (r as { data: Array<Record<string, unknown>> }).data;441 for (const entry of data) {442 expect("privateKeyJwk" in entry).toBe(false);443 expect("passkey" in entry).toBe(false);444 }445 });446447 it("kind=card returns card summaries only (no full PAN)", async () => {448 const vault = await import("./vault");449 await vault.createVault("test-pass-12345");450 await vault.addEntry({451 type: "card",452 title: "Visa",453 cardNumber: "4111111111111111",454 last4: "1111",455 cardholderName: "JUNE",456 expMonth: "12",457 expYear: "2030",458 cvc: "123",459 } as unknown as Parameters<typeof vault.addEntry>[0]);460 const r = await dispatch({461 type: "AUTOFILL_QUERY",462 payload: { domain: "any.com", kind: "card" },463 });464 const data = (r as { data: Array<Record<string, unknown>> }).data;465 for (const entry of data) {466 expect("cardNumber" in entry).toBe(false);467 expect("cvc" in entry).toBe(false);468 }469 });470 });471472 473474 describe("RESTORE_FROM_CODEX adversarial", () => {475 const malformed = [476 { name: "empty vaultBlob", overrides: { vaultBlob: "" } },477 { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },478 { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },479 { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },480 { name: "negative timestamp", overrides: { timestamp: -1 } },481 { name: "zero timestamp", overrides: { timestamp: 0 } },482 { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },483 { name: "negative entryCount", overrides: { entryCount: -5 } },484 ];485 for (const { name, overrides } of malformed) {486 it(`malformed payload (${name}) returns ok=false without crash`, async () => {487 const payload = {488 vaultBlob: "ZGF0YQ==",489 timestamp: Date.now(),490 verifyCode: "PX-TEST-1234",491 vaultFingerprint: "ABCDEF1234567890",492 entryCount: 0,493 masterPassphrase: "secret",494 ...overrides,495 };496 const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload });497 expect(r).toBeDefined();498 expect(r.ok).toBe(false);499 });500 }501502 it("null payload does not crash", async () => {503 const r = await dispatch({504 type: "RESTORE_FROM_CODEX",505 payload: null,506 } as unknown as Message);507 expect(r).toBeDefined();508 expect(r.ok).toBe(false);509 });510 });511512 513514 describe("COPY handler adversarial", () => {515 for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {516 it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {517 const r = await dispatch({518 type: "COPY",519 payload: { text, label: "test", alreadyWritten: true },520 });521 expect(r).toBeDefined();522 });523 }524 });525526 527528 describe("FIRE_CANARY_PING webhook url validation", () => {529 const badUrls = [530 "javascript:alert(1)",531 "file:///etc/passwd",532 "data:text/plain,evil",533 "",534 "not a url",535 "ftp://example.com/hook",536 ];537 for (const url of badUrls) {538 it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {539 const r = await dispatch({540 type: "FIRE_CANARY_PING",541 payload: {542 webhookUrl: url,543 entryTitle: "x",544 trigger: "test",545 },546 });547 expect(r.ok).toBe(false);548 });549 }550 });551552 553554 describe("GET_ENTRY_FOR_FILL safety", () => {555 it("returns entry without passkey field for login entry", async () => {556 const vault = await import("./vault");557 await vault.createVault("test-pass-12345");558 const e = await vault.addEntry({559 title: "GitHub",560 username: "june",561 password: "x",562 url: "https://github.com",563 type: "login",564 });565 const r = await dispatch({566 type: "GET_ENTRY_FOR_FILL",567 payload: { id: e.id },568 });569 expect(r.ok).toBe(true);570 const data = (r as { data: Record<string, unknown> }).data;571 expect("passkey" in data).toBe(false);572 expect("privateKeyJwk" in data).toBe(false);573 });574575 it("missing id returns error", async () => {576 await setupUnlockedVault();577 const r = await dispatch({578 type: "GET_ENTRY_FOR_FILL",579 payload: { id: "not-a-real-id" },580 });581 expect(r.ok).toBe(false);582 });583584 it("locked vault returns error", async () => {585 const vault = await import("./vault");586 await vault.createVault("test-pass-12345");587 vault.lock();588 const r = await dispatch({589 type: "GET_ENTRY_FOR_FILL",590 payload: { id: "x" },591 });592 expect(r.ok).toBe(false);593 });594 });595596 597598 describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {599 async function dispatchFrom(600 message: Message,601 sender: chrome.runtime.MessageSender,602 ): Promise<MessageResponse> {603 if (!listener) throw new Error("listener not registered");604 return new Promise<MessageResponse>((resolve) => {605 listener!(message, sender, resolve);606 });607 }608609 async function addGithubEntry() {610 const vault = await import("./vault");611 await vault.createVault("test-pass-12345");612 return vault.addEntry({613 title: "GitHub",614 username: "june",615 password: "x",616 url: "https://github.com",617 type: "login",618 });619 }620621 it("tab-context request on the entry's domain returns the full entry", async () => {622 const e = await addGithubEntry();623 const r = await dispatchFrom(624 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,625 { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,626 );627 expect(r.ok).toBe(true);628 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");629 });630631 it("tab-context request on a mismatched domain returns entry_not_found with no leak", 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://evil.example/login" } } as chrome.runtime.MessageSender,636 );637 expect(r.ok).toBe(false);638 expect(r.error).toBe("entry_not_found");639 expect((r as { data?: unknown }).data).toBeUndefined();640 });641642 it("explicitSummon allows a cross-domain tab-context request", async () => {643 const e = await addGithubEntry();644 const r = await dispatchFrom(645 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,646 { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,647 );648 expect(r.ok).toBe(true);649 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");650 });651652 it("popup context (no tab/url) skips the origin check and returns the entry", async () => {653 const e = await addGithubEntry();654 const r = await dispatchFrom(655 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,656 { id: "peach-extension" } as chrome.runtime.MessageSender,657 );658 expect(r.ok).toBe(true);659 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");660 });661 });662663 664665 describe("ADD_ENTRY locked-state safety", () => {666 it("locked vault: ADD_ENTRY returns vault locked", async () => {667 const r = await dispatch({668 type: "ADD_ENTRY",669 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },670 });671 expect(r.ok).toBe(false);672 });673674 it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {675 await setupUnlockedVault();676 const r = await dispatch({677 type: "ADD_ENTRY",678 payload: { title: "x", username: "u", password: "", url: "https://e.com" },679 });680 expect(r.ok).toBe(true);681 });682 });683684 685686 describe("DELETE_ENTRY adversarial", () => {687 const badIds = [688 "",689 " ",690 "not-a-uuid",691 String("a").repeat(10000),692 "00000000-0000-0000-0000-000000000000",693 "<script>",694 "\x00",695 ];696 for (const id of badIds) {697 it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {698 await setupUnlockedVault();699 const r = await dispatch({700 type: "DELETE_ENTRY",701 payload: { id },702 });703 expect(r.ok).toBe(false);704 });705 }706 });707708 709710 describe("BATCH_ADD_ENTRIES adversarial", () => {711 it("empty array does not crash", async () => {712 await setupUnlockedVault();713 const r = await dispatch({714 type: "BATCH_ADD_ENTRIES",715 payload: { entries: [] },716 });717 expect(r).toBeDefined();718 });719720 it("non-array entries does not crash", async () => {721 await setupUnlockedVault();722 const r = await dispatch({723 type: "BATCH_ADD_ENTRIES",724 payload: { entries: "not-an-array" as unknown as unknown[] },725 });726 expect(r).toBeDefined();727 });728729 it("mostly-malformed entries does not crash", async () => {730 await setupUnlockedVault();731 const r = await dispatch({732 type: "BATCH_ADD_ENTRIES",733 payload: {734 entries: [735 null,736 undefined,737 42,738 "string",739 { title: "ok", username: "u", password: "p", url: "https://e.com" },740 ] as unknown as unknown[],741 },742 });743 expect(r).toBeDefined();744 });745 });746747 748749 describe("SEARCH_VAULT adversarial", () => {750 const queries = [751 "",752 " ",753 "x",754 "a".repeat(1000),755 "🔑",756 "<script>",757 "' OR '1'='1",758 "../../",759 "\x00",760 ];761 for (const query of queries) {762 it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {763 await setupUnlockedVault();764 const r = await dispatch({765 type: "SEARCH_VAULT",766 payload: { query },767 });768 expect(r).toBeDefined();769 });770 }771 });772773 774775 describe("UPDATE_SETTINGS adversarial", () => {776 const patches = [777 { name: "empty", patch: {} },778 { name: "junk keys", patch: { __proto__: { polluted: true } } },779 { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },780 { name: "negative autolock", patch: { autoLockMinutes: -5 } },781 { name: "string autolock", patch: { autoLockMinutes: "five" } },782 { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },783 ];784 for (const { name, patch } of patches) {785 it(`patch ${name} does not crash`, async () => {786 await setupUnlockedVault();787 const r = await dispatch({788 type: "UPDATE_SETTINGS",789 payload: patch as Record<string, unknown>,790 });791 expect(r).toBeDefined();792 });793 }794795 it("prototype pollution check: Object.prototype.polluted stays unset", async () => {796 await setupUnlockedVault();797 await dispatch({798 type: "UPDATE_SETTINGS",799 payload: JSON.parse(800 '{"__proto__": {"polluted": "yes"}}'801 ) as Record<string, unknown>,802 });803 804 expect(({} as any).polluted).toBeUndefined();805 });806 });807808 809810 describe("wide adversarial payload matrix", () => {811 const adversarialPayloads: Array<{ name: string; payload: unknown }> = [812 { name: "true", payload: true },813 { name: "false", payload: false },814 { name: "NaN", payload: NaN },815 { name: "Infinity", payload: Infinity },816 { name: "-Infinity", payload: -Infinity },817 { name: "BigInt-like", payload: { type: "bigint" } },818 { name: "Date", payload: new Date() },819 { name: "RegExp", payload: /pattern/ },820 { name: "Symbol-string", payload: Symbol("x").toString() },821 { name: "circular reference", payload: (() => {822 const o: Record<string, unknown> = {};823 o.self = o;824 return o;825 })() },826 ];827828 const fastHandlers = [829 "ADD_ENTRY",830 "UPDATE_ENTRY",831 "DELETE_ENTRY",832 "AUTOFILL_QUERY",833 "GET_ENTRY_FOR_FILL",834 "UPDATE_SETTINGS",835 "SEARCH_VAULT",836 "SAVE_NOTE",837 "DELETE_NOTE",838 "PURGE_ENTRY",839 "RESTORE_ENTRY",840 "PURGE_EXPIRED_TRASH",841 "ADD_ADDITIONAL_URL",842 "BATCH_ADD_ENTRIES",843 "RESTORE_ENTRIES",844 "ACTIVATE_LICENSE",845 "APPROVE_PASSKEY",846 "DENY_PASSKEY",847 "SET_PASSKEY_PROVIDER_ENABLED",848 "RESTORE_VAULT_SNAPSHOT",849 "VERIFY_MAIN_PASSWORD",850 "SETUP_DURESS",851 ];852853 for (const handler of fastHandlers) {854 for (const { name, payload } of adversarialPayloads) {855 it(`${handler} + ${name} payload resolves without crash`, async () => {856 const r = await dispatch({ type: handler, payload } as unknown as Message);857 expect(r).toBeDefined();858 });859 }860 }861 });862863 864865 describe("foreign sender rejection matrix", () => {866 const safeQuickHandlers = [867 "GET_ENTRIES",868 "IS_UNLOCKED",869 "HAS_VAULT",870 "GET_VAULT_STATUS",871 "GET_SETTINGS",872 "GET_DELETED_ENTRIES",873 "COUNT_DUPLICATES",874 "GET_VAULT_HISTORY",875 "ADD_ENTRY",876 "DELETE_ENTRY",877 "AUTOFILL_QUERY",878 "GET_ENTRY_FOR_FILL",879 "SAVE_NOTE",880 "DELETE_NOTE",881 "GET_NOTES",882 "SEARCH_VAULT",883 "GET_LICENSE",884 "GET_LICENSE_KEY",885 "GET_RECOVERY_WORDS",886 "VALIDATE_RECOVERY_WORDS",887 "RECOVER_WITH_WORDS",888 "RESTORE_FROM_CODEX",889 "GET_CODEX_VAULT_DATA",890 "ESTIMATE_CODEX_PAGES",891 ];892 for (const handler of safeQuickHandlers) {893 it(`${handler} from foreign sender returns invalid sender`, async () => {894 if (!listener) throw new Error("listener not registered");895 const r = await new Promise<MessageResponse>((resolve) => {896 listener!(897 { type: handler, payload: {} } as Message,898 { id: "foreign-extension-id" } as chrome.runtime.MessageSender,899 resolve900 );901 });902 expect(r.ok).toBe(false);903 expect(r.error).toBe("invalid_sender");904 });905 }906 });907908 909910 describe("ADD_ENTRY payload shape robustness", () => {911 const shapes = [912 { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },913 { name: "no url", body: { title: "t", username: "u", password: "p" } },914 { name: "title only", body: { title: "t", password: "p" } },915 { name: "spurious type", body: { type: "login", title: "t", password: "p" } },916 { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },917 { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },918 { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },919 { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },920 { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },921 { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },922 { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },923 { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },924 ];925 for (const { name, body } of shapes) {926 it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {927 await setupUnlockedVault();928 const r = await dispatch({929 type: "ADD_ENTRY",930 payload: body as Parameters<typeof dispatch>[0]["payload"],931 } as Message);932 expect(r).toBeDefined();933 });934 }935 });936937 938939 describe("UPDATE_ENTRY patch shape robustness", () => {940 const patches = [941 { name: "title only", patch: { title: "New" } },942 { name: "password only", patch: { password: "newpass" } },943 { name: "url only", patch: { url: "https://e2.com" } },944 { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },945 { name: "unknown field", patch: { totallyMadeUp: "x" } },946 { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },947 { name: "createdAt forbidden", patch: { createdAt: 0 } },948 { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },949 { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },950 ];951 for (const { name, patch } of patches) {952 it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {953 const vault = await import("./vault");954 await vault.createVault("test-pass-12345");955 const e = await vault.addEntry({956 title: "x",957 username: "u",958 password: "p",959 url: "https://e.com",960 type: "login",961 });962 const r = await dispatch({963 type: "UPDATE_ENTRY",964 payload: { id: e.id, patch },965 });966 expect(r).toBeDefined();967 });968 }969 });970971 972973 describe("vault lifecycle adversarial sequences", () => {974 for (let i = 0; i < 20; i++) {975 it(`random message sequence #${i} does not crash`, async () => {976 const vault = await import("./vault");977 await vault.createVault("test-pass-12345");978 const handlers = [979 { type: "GET_ENTRIES" },980 { type: "IS_UNLOCKED" },981 { type: "GET_VAULT_STATUS" },982 { type: "HAS_VAULT" },983 { type: "GET_DELETED_ENTRIES" },984 { type: "GET_SETTINGS" },985 { type: "COUNT_DUPLICATES" },986 { type: "GET_VAULT_HISTORY" },987 ] as Array<Message>;988 for (const h of handlers) {989 const r = await dispatch(h);990 expect(r).toBeDefined();991 }992 });993 }994 });995996 997998 describe("read-only handlers when locked", () => {999 const safeWhenLocked = [1000 "IS_UNLOCKED",1001 "HAS_VAULT",1002 "GET_VAULT_STATUS",1003 "GET_SETTINGS",1004 ];1005 for (const type of safeWhenLocked) {1006 it(`${type} works without crash when locked`, async () => {1007 const r = await dispatch({ type } as unknown as Message);1008 expect(r).toBeDefined();1009 });1010 }10111012 const gatedWhenLocked = [1013 "GET_ENTRIES",1014 "GET_DELETED_ENTRIES",1015 "COUNT_DUPLICATES",1016 "GET_IDENTITIES",1017 "GET_IDENTITY_SUGGESTIONS",1018 ];1019 for (const type of gatedWhenLocked) {1020 it(`${type} returns ok=false when locked`, async () => {1021 const r = await dispatch({ type } as unknown as Message);1022 expect(r).toBeDefined();1023 1024 if (r.ok === true) {1025 1026 const data = (r as { data: unknown }).data;1027 expect(1028 data == null ||1029 (Array.isArray(data) && data.length === 0) ||1030 data === 01031 ).toBe(true);1032 } else {1033 expect(r.ok).toBe(false);1034 }1035 });1036 }1037 });10381039 10401041 describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1042 1043 1044 1045 1046 const cases: Array<{ type: string; bad: unknown[] }> = [1047 {1048 type: "ADD_ENTRY",1049 bad: [null, "string", 42, [], true],1050 },1051 {1052 type: "BATCH_ADD_ENTRIES",1053 bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1054 },1055 {1056 type: "UPDATE_ENTRY",1057 bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1058 },1059 {1060 type: "RESTORE_ENTRIES",1061 bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1062 },1063 {1064 type: "UPDATE_SETTINGS",1065 bad: [null, "string", 42, []],1066 },1067 ];10681069 for (const { type, bad } of cases) {1070 for (const payload of bad) {1071 const label = JSON.stringify(payload);1072 it(`${type} rejects malformed payload: ${label}`, async () => {1073 await setupUnlockedVault();1074 const r = await dispatch({ type, payload } as unknown as Message);1075 expect(r.ok).toBe(false);1076 const err = (r as { error?: string }).error ?? "";1077 expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1078 });1079 }1080 }1081 });10821083 10841085 describe("AUD-002 payload validation", () => {1086 describe("SEARCH_VAULT", () => {1087 const badQueries: Array<[string, unknown]> = [1088 ["null", null],1089 ["numeric", 42],1090 ["boolean", true],1091 ["object", { evil: true }],1092 ["array", []],1093 ["missing", undefined],1094 ];1095 for (const [name, query] of badQueries) {1096 it(`rejects ${name} query`, async () => {1097 await setupUnlockedVault();1098 const r = await dispatch({1099 type: "SEARCH_VAULT",1100 payload: { query },1101 } as unknown as Message);1102 expect(r.ok).toBe(false);1103 expect((r as { error?: string }).error).toBe("invalid_payload");1104 });1105 }11061107 it("accepts valid string query", async () => {1108 await setupUnlockedVault();1109 const r = await dispatch({1110 type: "SEARCH_VAULT",1111 payload: { query: "github" },1112 });1113 expect(r.ok).toBe(true);1114 });1115 });11161117 describe("COPY", () => {1118 const bads: Array<[string, unknown, unknown]> = [1119 ["null text", null, "pw"],1120 ["numeric text", 42, "pw"],1121 ["object text", { evil: true }, "pw"],1122 ["missing text", undefined, "pw"],1123 ["null label", "secret", null],1124 ["numeric label", "secret", 42],1125 ];1126 for (const [name, text, label] of bads) {1127 it(`rejects ${name}`, async () => {1128 await setupUnlockedVault();1129 const r = await dispatch({1130 type: "COPY",1131 payload: { text, label },1132 } as unknown as Message);1133 expect(r.ok).toBe(false);1134 expect((r as { error?: string }).error).toBe("invalid_payload");1135 });1136 }1137 });11381139 describe("ML_PREDICT_BATCH", () => {1140 const bads: Array<[string, unknown]> = [1141 ["null", null],1142 ["object", { not: "array" }],1143 ["string", "features"],1144 ["number", 42],1145 ["missing", undefined],1146 ];1147 for (const [name, featuresArray] of bads) {1148 it(`rejects ${name} featuresArray`, async () => {1149 const r = await dispatch({1150 type: "ML_PREDICT_BATCH",1151 payload: { featuresArray },1152 } as unknown as Message);1153 expect(r.ok).toBe(false);1154 expect((r as { error?: string }).error).toBe("invalid_payload");1155 });1156 }11571158 it("accepts empty array featuresArray", async () => {1159 const r = await dispatch({1160 type: "ML_PREDICT_BATCH",1161 payload: { featuresArray: [] },1162 } as unknown as Message);1163 expect((r as { error?: string }).error).not.toBe("invalid_payload");1164 });1165 });1166 });1167});