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 = { id: "peach-extension" } as chrome.runtime.MessageSender,162 ): Promise<MessageResponse> {163 if (!listener) throw new Error("listener not registered");164 return new Promise<MessageResponse>((resolve) => {165 const keepOpen = listener!(message, sender, resolve);166 expect(keepOpen).toBe(true);167 });168 }169170 171 172 const EXTENSION_PAGE_SENDER = {173 id: "peach-extension",174 url: "chrome-extension://peach-extension/popup.html",175 } as chrome.runtime.MessageSender;176177 async function setupUnlockedVault() {178 const vault = await import("./vault");179 await vault.createVault("test-pass-12345");180 }181182 183184 describe("payload no-crash matrix", () => {185 for (const type of PAYLOAD_HANDLERS) {186 it(`${type} with null payload does not crash`, async () => {187 const r = await dispatch({ type, payload: null } as unknown as Message);188 expect(r).toBeDefined();189 });190191 it(`${type} with undefined payload does not crash`, async () => {192 const r = await dispatch({193 type,194 payload: undefined,195 } as unknown as Message);196 expect(r).toBeDefined();197 });198199 it(`${type} with empty object payload does not crash`, async () => {200 const r = await dispatch({ type, payload: {} } as unknown as Message);201 expect(r).toBeDefined();202 });203204 it(`${type} with deeply nested payload does not crash`, async () => {205 const nested: Record<string, unknown> = {};206 let cur: Record<string, unknown> = nested;207 for (let i = 0; i < 50; i++) {208 const child: Record<string, unknown> = {};209 cur.child = child;210 cur = child;211 }212 const r = await dispatch({213 type,214 payload: nested,215 } as unknown as Message);216 expect(r).toBeDefined();217 });218219 it(`${type} with array payload does not crash`, async () => {220 const r = await dispatch({221 type,222 payload: [1, 2, 3],223 } as unknown as Message);224 expect(r).toBeDefined();225 });226227 it(`${type} with string payload does not crash`, async () => {228 const r = await dispatch({229 type,230 payload: "garbage",231 } as unknown as Message);232 expect(r).toBeDefined();233 });234235 it(`${type} with number payload does not crash`, async () => {236 const r = await dispatch({237 type,238 payload: 42,239 } as unknown as Message);240 expect(r).toBeDefined();241 });242 }243 });244245 246 247 248 249 250251 describe("AUD-007 extended payload no-crash sweep", () => {252 const extraPayloads: Array<{ name: string; payload: unknown }> = [253 { name: "boolean true", payload: true },254 { name: "boolean false", payload: false },255 { name: "negative number", payload: -1 },256 { name: "NaN", payload: NaN },257 { name: "Infinity", payload: Infinity },258 { name: "empty string", payload: "" },259 { name: "very long string", payload: "A".repeat(10_000) },260 { name: "unicode string", payload: "私のパスワード🔑" },261 { name: "RTL string", payload: "مفتاح" },262 { name: "null bytes in string", payload: "a\x00b\x00c" },263 { name: "html string", payload: "<script>alert(1)</script>" },264 { name: "sql string", payload: "'; DROP TABLE entries; --" },265 { name: "path traversal string", payload: "../../etc/passwd" },266 { name: "object with null prototype-ish", payload: { __proto__: null } },267 { name: "object with prototype pollution attempt", payload: { __proto__: { polluted: true } } },268 { name: "object with constructor pollution", payload: { constructor: { prototype: { polluted: true } } } },269 { name: "deeply nested array", payload: [[[[[[[[[[]]]]]]]]]] },270 { name: "long array", payload: new Array(1_000).fill(0) },271 { name: "mixed array", payload: [null, undefined, 1, "x", true, {}, []] },272 { name: "function-like object", payload: { call: () => 1, apply: () => 1 } },273 { name: "Date object", payload: new Date(0) },274 { name: "BigInt-ish payload", payload: { n: "9007199254740993" } },275 { name: "circular-safe placeholder", payload: { self: "[Circular]" } },276 ];277278 for (const { name, payload } of extraPayloads) {279 for (const type of PAYLOAD_HANDLERS) {280 it(`${type} with ${name} payload does not crash`, async () => {281 const r = await dispatch({ type, payload } as unknown as Message);282 expect(r).toBeDefined();283 expect(typeof r.ok).toBe("boolean");284 });285 }286 }287 });288289 290291 describe("unknown message types", () => {292 const unknownTypes = [293 "TOTALLY_UNKNOWN_TYPE",294 "",295 "X",296 "lowercase_type",297 "with spaces in type",298 "with-dashes",299 "WITH.DOTS",300 "🌸emoji_type",301 "Unicode_üñïcödé",302 "IMPORT_PEACHSCRIPT",303 "RESTORE_FROM_PEACHSCRIPT",304 "DESTROY_VAULT_QUIETLY",305 "ESCALATE_PRIVILEGE",306 ];307308 for (const type of unknownTypes) {309 it(`unknown type "${type}" returns error response`, async () => {310 const r = await dispatch({ type } as unknown as Message);311 expect(r).toBeDefined();312 expect(r.ok).toBe(false);313 });314 }315316 it("missing type field does not crash", async () => {317 const r = await dispatch({} as unknown as Message);318 expect(r).toBeDefined();319 });320321 it("type=null does not crash", async () => {322 const r = await dispatch({ type: null } as unknown as Message);323 expect(r).toBeDefined();324 });325326 it("type=number does not crash", async () => {327 const r = await dispatch({ type: 42 } as unknown as Message);328 expect(r).toBeDefined();329 });330 });331332 333334 describe("sender validation", () => {335 it("foreign extension sender is rejected", async () => {336 if (!listener) throw new Error("listener not registered");337 const r = await new Promise<MessageResponse>((resolve) => {338 listener!(339 { type: "GET_ENTRIES" } as Message,340 { id: "foreign-extension" } as chrome.runtime.MessageSender,341 resolve342 );343 });344 expect(r.ok).toBe(false);345 expect(r.error).toBe("invalid_sender");346 });347348 it("sender with no id (extension UI) is accepted", async () => {349 if (!listener) throw new Error("listener not registered");350 await setupUnlockedVault();351 const r = await new Promise<MessageResponse>((resolve) => {352 listener!(353 { type: "GET_ENTRIES" } as Message,354 {} as chrome.runtime.MessageSender,355 resolve356 );357 });358 expect(r.ok).toBe(true);359 });360 });361362 363364 describe("AUTOFILL_QUERY adversarial", () => {365 async function withEntries(n: number) {366 const vault = await import("./vault");367 await vault.createVault("test-pass-12345");368 for (let i = 0; i < n; i++) {369 await vault.addEntry({370 title: `E${i}`,371 username: `u${i}`,372 password: "p",373 url: `https://example${i}.com`,374 type: "login",375 });376 }377 }378379 it("empty domain returns empty results", async () => {380 await withEntries(3);381 const r = await dispatch({382 type: "AUTOFILL_QUERY",383 payload: { domain: "" },384 });385 expect(r.ok).toBe(true);386 expect((r as { data: unknown[] }).data).toEqual([]);387 });388389 const adversarialDomains = [390 "*",391 ".*",392 "%",393 "<script>alert(1)</script>",394 "' OR '1'='1",395 "../../../etc/passwd",396 "\x00github.com",397 "github.com\x00",398 "GITHUB.COM",399 "github.com/path",400 "https://github.com",401 "javascript:alert(1)",402 " ",403 " ",404 "\t",405 "\n",406 String("a").repeat(5000),407 ];408 for (const domain of adversarialDomains) {409 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not crash`, async () => {410 await withEntries(3);411 const r = await dispatch({412 type: "AUTOFILL_QUERY",413 payload: { domain },414 });415 expect(r.ok).toBe(true);416 });417418 it(`adversarial domain ${JSON.stringify(domain.slice(0, 30))} does not match all entries`, async () => {419 await withEntries(5);420 const r = await dispatch({421 type: "AUTOFILL_QUERY",422 payload: { domain },423 });424 const data = (r as { data: unknown[] }).data;425 426 expect(data.length).toBeLessThan(5);427 });428 }429430 it("locked vault: AUTOFILL_QUERY returns error", async () => {431 const vault = await import("./vault");432 await vault.createVault("test-pass-12345");433 vault.lock();434 const r = await dispatch({435 type: "AUTOFILL_QUERY",436 payload: { domain: "github.com" },437 });438 expect(r.ok).toBe(false);439 });440441 it("response never leaks privateKeyJwk", async () => {442 await withEntries(3);443 const r = await dispatch({444 type: "AUTOFILL_QUERY",445 payload: { domain: "example0.com" },446 });447 const data = (r as { data: Array<Record<string, unknown>> }).data;448 for (const entry of data) {449 expect("privateKeyJwk" in entry).toBe(false);450 expect("passkey" in entry).toBe(false);451 }452 });453454 it("kind=card returns card summaries only (no full PAN)", async () => {455 const vault = await import("./vault");456 await vault.createVault("test-pass-12345");457 await vault.addEntry({458 type: "card",459 title: "Visa",460 cardNumber: "4111111111111111",461 last4: "1111",462 cardholderName: "JUNE",463 expMonth: "12",464 expYear: "2030",465 cvc: "123",466 } as unknown as Parameters<typeof vault.addEntry>[0]);467 const r = await dispatch({468 type: "AUTOFILL_QUERY",469 payload: { domain: "any.com", kind: "card" },470 });471 const data = (r as { data: Array<Record<string, unknown>> }).data;472 for (const entry of data) {473 expect("cardNumber" in entry).toBe(false);474 expect("cvc" in entry).toBe(false);475 }476 });477 });478479 480481 describe("RESTORE_FROM_CODEX adversarial", () => {482 const malformed = [483 { name: "empty vaultBlob", overrides: { vaultBlob: "" } },484 { name: "garbage vaultBlob", overrides: { vaultBlob: "!!!not-base64!!!" } },485 { name: "wrong fingerprint", overrides: { vaultFingerprint: "0".repeat(16) } },486 { name: "wrong passphrase", overrides: { masterPassphrase: "definitely wrong" } },487 { name: "negative timestamp", overrides: { timestamp: -1 } },488 { name: "zero timestamp", overrides: { timestamp: 0 } },489 { name: "huge entryCount", overrides: { entryCount: 1_000_000 } },490 { name: "negative entryCount", overrides: { entryCount: -5 } },491 ];492 for (const { name, overrides } of malformed) {493 it(`malformed payload (${name}) returns ok=false without crash`, async () => {494 const payload = {495 vaultBlob: "ZGF0YQ==",496 timestamp: Date.now(),497 verifyCode: "PX-TEST-1234",498 vaultFingerprint: "ABCDEF1234567890",499 entryCount: 0,500 masterPassphrase: "secret",501 ...overrides,502 };503 const r = await dispatch({ type: "RESTORE_FROM_CODEX", payload }, EXTENSION_PAGE_SENDER);504 expect(r).toBeDefined();505 expect(r.ok).toBe(false);506 });507 }508509 it("null payload does not crash", async () => {510 const r = await dispatch({511 type: "RESTORE_FROM_CODEX",512 payload: null,513 } as unknown as Message, EXTENSION_PAGE_SENDER);514 expect(r).toBeDefined();515 expect(r.ok).toBe(false);516 });517 });518519 520521 describe("COPY handler adversarial", () => {522 for (const text of ["", "x", "a".repeat(10000), "\x00\x00\x00", "🔑"]) {523 it(`COPY of ${JSON.stringify(text.slice(0, 20))} resolves`, async () => {524 const r = await dispatch({525 type: "COPY",526 payload: { text, label: "test", alreadyWritten: true },527 });528 expect(r).toBeDefined();529 });530 }531 });532533 534535 describe("FIRE_CANARY_PING webhook url validation", () => {536 const badUrls = [537 "javascript:alert(1)",538 "file:///etc/passwd",539 "data:text/plain,evil",540 "",541 "not a url",542 "ftp://example.com/hook",543 ];544 for (const url of badUrls) {545 it(`rejects bad webhook URL ${JSON.stringify(url)}`, async () => {546 const r = await dispatch({547 type: "FIRE_CANARY_PING",548 payload: {549 webhookUrl: url,550 entryTitle: "x",551 trigger: "test",552 },553 });554 expect(r.ok).toBe(false);555 });556 }557 });558559 560561 describe("GET_ENTRY_FOR_FILL safety", () => {562 it("returns entry without passkey field for login entry", async () => {563 const vault = await import("./vault");564 await vault.createVault("test-pass-12345");565 const e = await vault.addEntry({566 title: "GitHub",567 username: "june",568 password: "x",569 url: "https://github.com",570 type: "login",571 });572 const r = await dispatch({573 type: "GET_ENTRY_FOR_FILL",574 payload: { id: e.id },575 });576 expect(r.ok).toBe(true);577 const data = (r as { data: Record<string, unknown> }).data;578 expect("passkey" in data).toBe(false);579 expect("privateKeyJwk" in data).toBe(false);580 });581582 it("missing id returns error", async () => {583 await setupUnlockedVault();584 const r = await dispatch({585 type: "GET_ENTRY_FOR_FILL",586 payload: { id: "not-a-real-id" },587 });588 expect(r.ok).toBe(false);589 });590591 it("locked vault returns error", async () => {592 const vault = await import("./vault");593 await vault.createVault("test-pass-12345");594 vault.lock();595 const r = await dispatch({596 type: "GET_ENTRY_FOR_FILL",597 payload: { id: "x" },598 });599 expect(r.ok).toBe(false);600 });601 });602603 604605 describe("GET_ENTRY_FOR_EDIT origin re-validation (PCH-01-006)", () => {606 async function dispatchFrom(607 message: Message,608 sender: chrome.runtime.MessageSender,609 ): Promise<MessageResponse> {610 if (!listener) throw new Error("listener not registered");611 return new Promise<MessageResponse>((resolve) => {612 listener!(message, sender, resolve);613 });614 }615616 async function addGithubEntry() {617 const vault = await import("./vault");618 await vault.createVault("test-pass-12345");619 return vault.addEntry({620 title: "GitHub",621 username: "june",622 password: "x",623 url: "https://github.com",624 type: "login",625 });626 }627628 it("tab-context request on the entry's domain returns the full entry", async () => {629 const e = await addGithubEntry();630 const r = await dispatchFrom(631 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,632 { id: "peach-extension", tab: { url: "https://github.com/login" } } as chrome.runtime.MessageSender,633 );634 expect(r.ok).toBe(true);635 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");636 });637638 it("tab-context request on a mismatched domain returns entry_not_found with no leak", async () => {639 const e = await addGithubEntry();640 const r = await dispatchFrom(641 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,642 { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,643 );644 expect(r.ok).toBe(false);645 expect(r.error).toBe("entry_not_found");646 expect((r as { data?: unknown }).data).toBeUndefined();647 });648649 it("explicitSummon allows a cross-domain tab-context request", async () => {650 const e = await addGithubEntry();651 const r = await dispatchFrom(652 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id, explicitSummon: true } } as Message,653 { id: "peach-extension", tab: { url: "https://evil.example/login" } } as chrome.runtime.MessageSender,654 );655 expect(r.ok).toBe(true);656 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");657 });658659 it("popup context (no tab/url) skips the origin check and returns the entry", async () => {660 const e = await addGithubEntry();661 const r = await dispatchFrom(662 { type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } } as Message,663 { id: "peach-extension" } as chrome.runtime.MessageSender,664 );665 expect(r.ok).toBe(true);666 expect((r as { data: Record<string, unknown> }).data.username).toBe("june");667 });668 });669670 671672 describe("destructive ops gated to extension-page senders (PCH-01-018)", () => {673 async function dispatchFrom(674 message: Message,675 sender: chrome.runtime.MessageSender,676 ): Promise<MessageResponse> {677 if (!listener) throw new Error("listener not registered");678 return new Promise<MessageResponse>((resolve) => {679 listener!(message, sender, resolve);680 });681 }682 const DESTRUCTIVE = [683 "DESTROY_VAULT",684 "CLEAR_VAULT_ENTRIES",685 "RESTORE_VAULT_SNAPSHOT",686 "RESTORE_FROM_CODEX",687 ] as const;688 const SENSITIVE = [689 "GET_ENTRIES",690 "GET_RECOVERY_WORDS",691 "RESTORE_ENTRIES",692 "UNPAIR_DEVICE",693 "CONFIRM_VOUCHED_DEVICE",694 "ROTATE_SYNC_IDENTITY",695 ] as const;696 const contentScript = {697 id: "peach-extension",698 url: "https://evil.test/",699 } as chrome.runtime.MessageSender;700 const extensionPage = {701 id: "peach-extension",702 url: "chrome-extension://peach-extension/popup.html",703 } as chrome.runtime.MessageSender;704705 for (const type of DESTRUCTIVE) {706 it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {707 await setupUnlockedVault();708 const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);709 expect(r).toEqual({ ok: false, error: "invalid_sender" });710 });711712 it(`${type} from an extension-page sender passes the origin gate`, async () => {713 await setupUnlockedVault();714 const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);715 716 expect(r.error).not.toBe("invalid_sender");717 });718 }719720 for (const type of SENSITIVE) {721 it(`${type} from a content-script sender is rejected as invalid_sender`, async () => {722 const r = await dispatchFrom({ type, payload: {} } as Message, contentScript);723 expect(r).toEqual({ ok: false, error: "invalid_sender" });724 });725726 it(`${type} from an extension-page sender reaches the handler`, async () => {727 const r = await dispatchFrom({ type, payload: {} } as Message, extensionPage);728 expect(r.error).not.toBe("invalid_sender");729 });730 }731732 it("GET_LOCAL_IDENTITY from a content-script sender is rejected (audit 2026-08-20 #4)", async () => {733 const r = await dispatchFrom({ type: "GET_LOCAL_IDENTITY", payload: {} } as Message, contentScript);734 expect(r).toEqual({ ok: false, error: "invalid_sender" });735 });736 });737738 739740 describe("ADD_ENTRY locked-state safety", () => {741 it("locked vault: ADD_ENTRY returns vault locked", async () => {742 const r = await dispatch({743 type: "ADD_ENTRY",744 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },745 });746 expect(r.ok).toBe(false);747 });748749 it("ADD_ENTRY with no password and no passkey succeeds for email-only accounts", async () => {750 await setupUnlockedVault();751 const r = await dispatch({752 type: "ADD_ENTRY",753 payload: { title: "x", username: "u", password: "", url: "https://e.com" },754 });755 expect(r.ok).toBe(true);756 });757 });758759 760761 describe("DELETE_ENTRY adversarial", () => {762 const badIds = [763 "",764 " ",765 "not-a-uuid",766 String("a").repeat(10000),767 "00000000-0000-0000-0000-000000000000",768 "<script>",769 "\x00",770 ];771 for (const id of badIds) {772 it(`unknown id ${JSON.stringify(id.slice(0, 30))} returns error`, async () => {773 await setupUnlockedVault();774 const r = await dispatch({775 type: "DELETE_ENTRY",776 payload: { id },777 });778 expect(r.ok).toBe(false);779 });780 }781 });782783 784785 describe("BATCH_ADD_ENTRIES adversarial", () => {786 it("empty array does not crash", async () => {787 await setupUnlockedVault();788 const r = await dispatch({789 type: "BATCH_ADD_ENTRIES",790 payload: { entries: [] },791 });792 expect(r).toBeDefined();793 });794795 it("non-array entries does not crash", async () => {796 await setupUnlockedVault();797 const r = await dispatch({798 type: "BATCH_ADD_ENTRIES",799 payload: { entries: "not-an-array" as unknown as unknown[] },800 });801 expect(r).toBeDefined();802 });803804 it("mostly-malformed entries does not crash", async () => {805 await setupUnlockedVault();806 const r = await dispatch({807 type: "BATCH_ADD_ENTRIES",808 payload: {809 entries: [810 null,811 undefined,812 42,813 "string",814 { title: "ok", username: "u", password: "p", url: "https://e.com" },815 ] as unknown as unknown[],816 },817 });818 expect(r).toBeDefined();819 });820 });821822 823824 describe("SEARCH_VAULT adversarial", () => {825 const queries = [826 "",827 " ",828 "x",829 "a".repeat(1000),830 "🔑",831 "<script>",832 "' OR '1'='1",833 "../../",834 "\x00",835 ];836 for (const query of queries) {837 it(`query ${JSON.stringify(query.slice(0, 30))} does not crash`, async () => {838 await setupUnlockedVault();839 const r = await dispatch({840 type: "SEARCH_VAULT",841 payload: { query },842 });843 expect(r).toBeDefined();844 });845 }846 });847848 849850 describe("UPDATE_SETTINGS adversarial", () => {851 const patches = [852 { name: "empty", patch: {} },853 { name: "junk keys", patch: { __proto__: { polluted: true } } },854 { name: "very long autolock", patch: { autoLockMinutes: 99999999 } },855 { name: "negative autolock", patch: { autoLockMinutes: -5 } },856 { name: "string autolock", patch: { autoLockMinutes: "five" } },857 { name: "unknown nested", patch: { foo: { bar: { baz: 1 } } } },858 ];859 for (const { name, patch } of patches) {860 it(`patch ${name} does not crash`, async () => {861 await setupUnlockedVault();862 const r = await dispatch({863 type: "UPDATE_SETTINGS",864 payload: patch as Record<string, unknown>,865 });866 expect(r).toBeDefined();867 });868 }869870 it("prototype pollution check: Object.prototype.polluted stays unset", async () => {871 await setupUnlockedVault();872 await dispatch({873 type: "UPDATE_SETTINGS",874 payload: JSON.parse(875 '{"__proto__": {"polluted": "yes"}}'876 ) as Record<string, unknown>,877 });878 879 expect(({} as any).polluted).toBeUndefined();880 });881 });882883 884885 describe("wide adversarial payload matrix", () => {886 const adversarialPayloads: Array<{ name: string; payload: unknown }> = [887 { name: "true", payload: true },888 { name: "false", payload: false },889 { name: "NaN", payload: NaN },890 { name: "Infinity", payload: Infinity },891 { name: "-Infinity", payload: -Infinity },892 { name: "BigInt-like", payload: { type: "bigint" } },893 { name: "Date", payload: new Date() },894 { name: "RegExp", payload: /pattern/ },895 { name: "Symbol-string", payload: Symbol("x").toString() },896 { name: "circular reference", payload: (() => {897 const o: Record<string, unknown> = {};898 o.self = o;899 return o;900 })() },901 ];902903 const fastHandlers = [904 "ADD_ENTRY",905 "UPDATE_ENTRY",906 "DELETE_ENTRY",907 "AUTOFILL_QUERY",908 "GET_ENTRY_FOR_FILL",909 "UPDATE_SETTINGS",910 "SEARCH_VAULT",911 "SAVE_NOTE",912 "DELETE_NOTE",913 "PURGE_ENTRY",914 "RESTORE_ENTRY",915 "PURGE_EXPIRED_TRASH",916 "ADD_ADDITIONAL_URL",917 "BATCH_ADD_ENTRIES",918 "RESTORE_ENTRIES",919 "ACTIVATE_LICENSE",920 "APPROVE_PASSKEY",921 "DENY_PASSKEY",922 "SET_PASSKEY_PROVIDER_ENABLED",923 "RESTORE_VAULT_SNAPSHOT",924 "VERIFY_MAIN_PASSWORD",925 "SETUP_DURESS",926 ];927928 for (const handler of fastHandlers) {929 for (const { name, payload } of adversarialPayloads) {930 it(`${handler} + ${name} payload resolves without crash`, async () => {931 const r = await dispatch({ type: handler, payload } as unknown as Message);932 expect(r).toBeDefined();933 });934 }935 }936 });937938 939940 describe("foreign sender rejection matrix", () => {941 const safeQuickHandlers = [942 "GET_ENTRIES",943 "IS_UNLOCKED",944 "HAS_VAULT",945 "GET_VAULT_STATUS",946 "GET_SETTINGS",947 "GET_DELETED_ENTRIES",948 "COUNT_DUPLICATES",949 "GET_VAULT_HISTORY",950 "ADD_ENTRY",951 "DELETE_ENTRY",952 "AUTOFILL_QUERY",953 "GET_ENTRY_FOR_FILL",954 "SAVE_NOTE",955 "DELETE_NOTE",956 "GET_NOTES",957 "SEARCH_VAULT",958 "GET_LICENSE",959 "GET_LICENSE_KEY",960 "GET_RECOVERY_WORDS",961 "VALIDATE_RECOVERY_WORDS",962 "RECOVER_WITH_WORDS",963 "RESTORE_FROM_CODEX",964 "GET_CODEX_VAULT_DATA",965 "ESTIMATE_CODEX_PAGES",966 ];967 for (const handler of safeQuickHandlers) {968 it(`${handler} from foreign sender returns invalid sender`, async () => {969 if (!listener) throw new Error("listener not registered");970 const r = await new Promise<MessageResponse>((resolve) => {971 listener!(972 { type: handler, payload: {} } as Message,973 { id: "foreign-extension-id" } as chrome.runtime.MessageSender,974 resolve975 );976 });977 expect(r.ok).toBe(false);978 expect(r.error).toBe("invalid_sender");979 });980 }981 });982983 984985 describe("ADD_ENTRY payload shape robustness", () => {986 const shapes = [987 { name: "no title", body: { username: "u", password: "p", url: "https://e.com" } },988 { name: "no url", body: { title: "t", username: "u", password: "p" } },989 { name: "title only", body: { title: "t", password: "p" } },990 { name: "spurious type", body: { type: "login", title: "t", password: "p" } },991 { name: "unknown type", body: { type: "wat", title: "t", password: "p" } },992 { name: "with extra field", body: { title: "t", password: "p", extra: "ignored" } },993 { name: "url with port", body: { title: "t", password: "p", url: "https://e.com:8080" } },994 { name: "url with path", body: { title: "t", password: "p", url: "https://e.com/a/b/c" } },995 { name: "username as number", body: { title: "t", username: 42, password: "p", url: "https://e.com" } },996 { name: "password as number", body: { title: "t", password: 12345, url: "https://e.com" } },997 { name: "title as null", body: { title: null, password: "p", url: "https://e.com" } },998 { name: "title as object", body: { title: { wat: 1 }, password: "p", url: "https://e.com" } },999 ];1000 for (const { name, body } of shapes) {1001 it(`ADD_ENTRY shape ${name} resolves without crash`, async () => {1002 await setupUnlockedVault();1003 const r = await dispatch({1004 type: "ADD_ENTRY",1005 payload: body as Parameters<typeof dispatch>[0]["payload"],1006 } as Message);1007 expect(r).toBeDefined();1008 });1009 }1010 });10111012 10131014 describe("UPDATE_ENTRY patch shape robustness", () => {1015 const patches = [1016 { name: "title only", patch: { title: "New" } },1017 { name: "password only", patch: { password: "newpass" } },1018 { name: "url only", patch: { url: "https://e2.com" } },1019 { name: "many fields", patch: { title: "N", username: "u2", password: "p2", url: "https://e2.com" } },1020 { name: "unknown field", patch: { totallyMadeUp: "x" } },1021 { name: "deletedAt forbidden", patch: { deletedAt: Date.now() } },1022 { name: "createdAt forbidden", patch: { createdAt: 0 } },1023 { name: "modifiedAt patch", patch: { modifiedAt: Date.now() } },1024 { name: "id patch (should reject or ignore)", patch: { id: "new-id" } },1025 ];1026 for (const { name, patch } of patches) {1027 it(`UPDATE_ENTRY patch ${name} resolves without crash`, async () => {1028 const vault = await import("./vault");1029 await vault.createVault("test-pass-12345");1030 const e = await vault.addEntry({1031 title: "x",1032 username: "u",1033 password: "p",1034 url: "https://e.com",1035 type: "login",1036 });1037 const r = await dispatch({1038 type: "UPDATE_ENTRY",1039 payload: { id: e.id, patch },1040 });1041 expect(r).toBeDefined();1042 });1043 }1044 });10451046 10471048 describe("vault lifecycle adversarial sequences", () => {1049 for (let i = 0; i < 20; i++) {1050 it(`random message sequence #${i} does not crash`, async () => {1051 const vault = await import("./vault");1052 await vault.createVault("test-pass-12345");1053 const handlers = [1054 { type: "GET_ENTRIES" },1055 { type: "IS_UNLOCKED" },1056 { type: "GET_VAULT_STATUS" },1057 { type: "HAS_VAULT" },1058 { type: "GET_DELETED_ENTRIES" },1059 { type: "GET_SETTINGS" },1060 { type: "COUNT_DUPLICATES" },1061 { type: "GET_VAULT_HISTORY" },1062 ] as Array<Message>;1063 for (const h of handlers) {1064 const r = await dispatch(h);1065 expect(r).toBeDefined();1066 }1067 });1068 }1069 });10701071 10721073 describe("read-only handlers when locked", () => {1074 const safeWhenLocked = [1075 "IS_UNLOCKED",1076 "HAS_VAULT",1077 "GET_VAULT_STATUS",1078 "GET_SETTINGS",1079 ];1080 for (const type of safeWhenLocked) {1081 it(`${type} works without crash when locked`, async () => {1082 const r = await dispatch({ type } as unknown as Message);1083 expect(r).toBeDefined();1084 });1085 }10861087 const gatedWhenLocked = [1088 "GET_ENTRIES",1089 "GET_DELETED_ENTRIES",1090 "COUNT_DUPLICATES",1091 "GET_IDENTITIES",1092 "GET_IDENTITY_SUGGESTIONS",1093 ];1094 for (const type of gatedWhenLocked) {1095 it(`${type} returns ok=false when locked`, async () => {1096 const r = await dispatch({ type } as unknown as Message);1097 expect(r).toBeDefined();1098 1099 if (r.ok === true) {1100 1101 const data = (r as { data: unknown }).data;1102 expect(1103 data == null ||1104 (Array.isArray(data) && data.length === 0) ||1105 data === 01106 ).toBe(true);1107 } else {1108 expect(r.ok).toBe(false);1109 }1110 });1111 }1112 });11131114 11151116 describe("payload validation rejects malformed inputs (M2/QUA-002)", () => {1117 1118 1119 1120 1121 const cases: Array<{ type: string; bad: unknown[] }> = [1122 {1123 type: "ADD_ENTRY",1124 bad: [null, "string", 42, [], true],1125 },1126 {1127 type: "BATCH_ADD_ENTRIES",1128 bad: [null, { entries: "not-array" }, { entries: null }, { entries: 42 }, []],1129 },1130 {1131 type: "UPDATE_ENTRY",1132 bad: [null, {}, { id: 123 }, { id: "x" }, { id: "x", patch: "no" }, { id: "x", patch: null }, []],1133 },1134 {1135 type: "RESTORE_ENTRIES",1136 bad: [null, {}, { entries: "no" }, { entries: [], mode: "wrong" }, []],1137 },1138 {1139 type: "UPDATE_SETTINGS",1140 bad: [null, "string", 42, []],1141 },1142 ];11431144 for (const { type, bad } of cases) {1145 for (const payload of bad) {1146 const label = JSON.stringify(payload);1147 it(`${type} rejects malformed payload: ${label}`, async () => {1148 await setupUnlockedVault();1149 const r = await dispatch({ type, payload } as unknown as Message);1150 expect(r.ok).toBe(false);1151 const err = (r as { error?: string }).error ?? "";1152 expect(err).toMatch(/invalid_payload|vault[_ ]locked/);1153 });1154 }1155 }1156 });11571158 11591160 describe("AUD-002 payload validation", () => {1161 describe("SEARCH_VAULT", () => {1162 const badQueries: Array<[string, unknown]> = [1163 ["null", null],1164 ["numeric", 42],1165 ["boolean", true],1166 ["object", { evil: true }],1167 ["array", []],1168 ["missing", undefined],1169 ];1170 for (const [name, query] of badQueries) {1171 it(`rejects ${name} query`, async () => {1172 await setupUnlockedVault();1173 const r = await dispatch({1174 type: "SEARCH_VAULT",1175 payload: { query },1176 } as unknown as Message);1177 expect(r.ok).toBe(false);1178 expect((r as { error?: string }).error).toBe("invalid_payload");1179 });1180 }11811182 it("accepts valid string query", async () => {1183 await setupUnlockedVault();1184 const r = await dispatch({1185 type: "SEARCH_VAULT",1186 payload: { query: "github" },1187 });1188 expect(r.ok).toBe(true);1189 });1190 });11911192 describe("COPY", () => {1193 const bads: Array<[string, unknown, unknown]> = [1194 ["null text", null, "pw"],1195 ["numeric text", 42, "pw"],1196 ["object text", { evil: true }, "pw"],1197 ["missing text", undefined, "pw"],1198 ["null label", "secret", null],1199 ["numeric label", "secret", 42],1200 ];1201 for (const [name, text, label] of bads) {1202 it(`rejects ${name}`, async () => {1203 await setupUnlockedVault();1204 const r = await dispatch({1205 type: "COPY",1206 payload: { text, label },1207 } as unknown as Message);1208 expect(r.ok).toBe(false);1209 expect((r as { error?: string }).error).toBe("invalid_payload");1210 });1211 }1212 });12131214 describe("ML_PREDICT_BATCH", () => {1215 const bads: Array<[string, unknown]> = [1216 ["null", null],1217 ["object", { not: "array" }],1218 ["string", "features"],1219 ["number", 42],1220 ["missing", undefined],1221 ];1222 for (const [name, featuresArray] of bads) {1223 it(`rejects ${name} featuresArray`, async () => {1224 const r = await dispatch({1225 type: "ML_PREDICT_BATCH",1226 payload: { featuresArray },1227 } as unknown as Message);1228 expect(r.ok).toBe(false);1229 expect((r as { error?: string }).error).toBe("invalid_payload");1230 });1231 }12321233 it("accepts empty array featuresArray", async () => {1234 const r = await dispatch({1235 type: "ML_PREDICT_BATCH",1236 payload: { featuresArray: [] },1237 } as unknown as Message);1238 expect((r as { error?: string }).error).not.toBe("invalid_payload");1239 });1240 });1241 });1242});