1234567891011121314151617181920212223import { beforeEach, describe, expect, it, vi } from "vitest";2425type StorageShape = Record<string, unknown>;2627function makeChromeStorageMock() {28 const storage: StorageShape = {};29 const chrome = {30 storage: {31 local: {32 get: vi.fn(async (key?: string | string[]) => {33 if (!key) return { ...storage };34 if (typeof key === "string") return { [key]: storage[key] };35 return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(36 (acc, k) => { acc[k] = storage[k]; return acc; },37 {},38 );39 }),40 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),41 remove: vi.fn(async (keys: string | string[]) => {42 const ks = Array.isArray(keys) ? keys : [keys];43 for (const k of ks) delete storage[k];44 }),45 },46 session: {47 get: vi.fn(async () => ({})),48 set: vi.fn(async () => {}),49 remove: vi.fn(async () => {}),50 },51 },52 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },53 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },54 runtime: {55 id: "test-extension-id",56 getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),57 },58 } as unknown as typeof globalThis.chrome;59 return { chrome };60}6162const LEGIT = "https://example.com/login";63const FOREIGN = "https://attacker.example.net/steal";6465function pageSender(url: string): chrome.runtime.MessageSender {66 return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;67}6869const extensionSender = {70 url: "chrome-extension://test-extension-id/src/popup/index.html",71} as unknown as chrome.runtime.MessageSender;7273describe("PB-26-106: content-script entry write origin binding", () => {74 let vault: typeof import("./vault");75 let messages: typeof import("./messages");7677 beforeEach(async () => {78 vi.resetModules();79 const mock = makeChromeStorageMock();80 (globalThis as Record<string, unknown>).chrome = mock.chrome;81 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));82 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);8384 vault = await import("./vault");85 messages = await import("./messages");8687 await vault.createVault("test-messages-pass");88 });8990 async function addLegitEntry() {91 return vault.addEntry({92 type: "login",93 title: "Example",94 username: "victim",95 password: "hunter2",96 url: "https://example.com",97 });98 }99100 101 102 it("UPDATE_ENTRY from a foreign-origin sender against a known id is refused", async () => {103 const entry = await addLegitEntry();104105 const res = await messages.handleMessage(106 { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "pwned" } } },107 pageSender(FOREIGN),108 );109110 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });111 });112113 114 115 116 it("UPDATE_ENTRY from the entry's own origin cannot repoint it at a foreign domain", async () => {117 const entry = await addLegitEntry();118119 const res = await messages.handleMessage(120 {121 type: "UPDATE_ENTRY",122 payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },123 },124 pageSender(LEGIT),125 );126127 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });128 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);129 });130131 132 it("UPDATE_ENTRY from the entry's own origin still patches non-origin fields", async () => {133 const entry = await addLegitEntry();134135 const res = await messages.handleMessage(136 { type: "UPDATE_ENTRY", payload: { id: entry.id, patch: { title: "Renamed" } } },137 pageSender(LEGIT),138 );139140 expect(res).toMatchObject({ ok: true });141 expect(vault.getEntryById(entry.id)?.title).toBe("Renamed");142 });143144 145 it("ADD_ADDITIONAL_URL cannot attach a third-party origin to the entry", async () => {146 const entry = await addLegitEntry();147148 const res = await messages.handleMessage(149 {150 type: "ADD_ADDITIONAL_URL",151 payload: { id: entry.id, url: "https://attacker.example.net" },152 },153 pageSender(LEGIT),154 );155156 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });157 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);158 });159160 161 162 it("ADD_ADDITIONAL_URL is refused even for the sender's own origin", async () => {163 const entry = await addLegitEntry();164165 const res = await messages.handleMessage(166 {167 type: "ADD_ADDITIONAL_URL",168 payload: { id: entry.id, url: "https://new-site.example.org" },169 },170 pageSender("https://new-site.example.org/checkout"),171 );172173 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });174 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);175 });176177 178 179 180 it("REQUEST_REMEMBER_SITE queues the sender's own origin for the popup to confirm", async () => {181 const entry = await addLegitEntry();182183 const res = await messages.handleMessage(184 {185 type: "REQUEST_REMEMBER_SITE",186 payload: { id: entry.id, url: "https://new-site.example.org" },187 },188 pageSender("https://new-site.example.org/checkout"),189 );190191 expect(res).toMatchObject({ ok: true });192 expect(chrome.storage.session.set).toHaveBeenCalledWith({193 pendingRememberSite: { entryId: entry.id, url: "https://new-site.example.org" },194 });195 196 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);197 });198199 it("REQUEST_REMEMBER_SITE cannot queue a third-party origin", async () => {200 const entry = await addLegitEntry();201202 const res = await messages.handleMessage(203 {204 type: "REQUEST_REMEMBER_SITE",205 payload: { id: entry.id, url: "https://attacker.example.net" },206 },207 pageSender(LEGIT),208 );209210 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });211 expect(chrome.storage.session.set).not.toHaveBeenCalled();212 });213214 215 it("UPDATE_ENTRY from the entry's own origin may drop an additional url", async () => {216 const entry = await addLegitEntry();217 await vault.updateEntry(entry.id, { additionalUrls: ["https://example.com"] });218219 const res = await messages.handleMessage(220 {221 type: "UPDATE_ENTRY",222 payload: { id: entry.id, patch: { additionalUrls: [] } },223 },224 pageSender(LEGIT),225 );226227 expect(res).toMatchObject({ ok: true });228 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);229 });230231 it("ADD_ENTRY from a page sender cannot create an entry bound to a foreign site", async () => {232 const res = await messages.handleMessage(233 {234 type: "ADD_ENTRY",235 payload: {236 type: "login",237 title: "Bank",238 username: "u",239 password: "attacker-chosen",240 url: "https://bank.example.org",241 },242 },243 pageSender(LEGIT),244 );245246 expect(res).toMatchObject({ ok: false, error: "origin_binding_required" });247 expect(vault.getEntries()).toHaveLength(0);248 });249250 it("ADD_ENTRY from a page sender succeeds for its own origin", async () => {251 const res = await messages.handleMessage(252 {253 type: "ADD_ENTRY",254 payload: {255 type: "login",256 title: "Example",257 username: "u",258 password: "p",259 url: "https://example.com",260 },261 },262 pageSender(LEGIT),263 );264265 expect(res).toMatchObject({ ok: true });266 expect(vault.getEntries()).toHaveLength(1);267 });268269 270 271 it("ADD_ENTRY from the popup can still create an entry for any site", async () => {272 const res = await messages.handleMessage(273 {274 type: "ADD_ENTRY",275 payload: {276 type: "login",277 title: "Bank",278 username: "u",279 password: "p",280 url: "https://bank.example.org",281 },282 },283 extensionSender,284 );285286 expect(res).toMatchObject({ ok: true });287 expect(vault.getEntries()).toHaveLength(1);288 });289290 291 292 it("the repoint-then-read chain stays closed from the attacker origin", async () => {293 const entry = await addLegitEntry();294295 await messages.handleMessage(296 {297 type: "UPDATE_ENTRY",298 payload: { id: entry.id, patch: { additionalUrls: ["https://attacker.example.net"] } },299 },300 pageSender(FOREIGN),301 );302303 const res = await messages.handleMessage(304 { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },305 pageSender(FOREIGN),306 );307308 expect(res).toMatchObject({ ok: false, error: "entry_not_found" });309 expect(vault.getEntryById(entry.id)?.additionalUrls ?? []).toEqual([]);310 });311});