diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 0e1fc08..bca9835 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -1,6 +1,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks (must be declared before `route` is imported so the router's +// `import * as vault` / `import * as session` resolve to these doubles) --- + +// Partial mock: we override only the vault calls the new tests care about +// (fetchAndDecryptItem / fetchAndDecryptSettings / encryptAndWriteSettings) +// and let the real implementations of listItems / findByHostname / etc. +// continue to run for the other tests that don't need mocks. +vi.mock('../../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAndDecryptItem: vi.fn(), + fetchAndDecryptSettings: vi.fn(), + encryptAndWriteSettings: vi.fn(), + }; +}); + +vi.mock('../../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + import { route, type RouterState } from '../index'; import type { Request } from '../../../shared/messages'; +import type { Item } from '../../../shared/types'; +import * as vault from '../../vault'; +import * as session from '../../session'; // --- chrome.* shim --- @@ -160,3 +188,155 @@ describe('get_autofill_candidates uses sender.tab.url', () => { } }); }); + +// --- fill_credentials TOCTOU + origin verification --- + +describe('fill_credentials captured-tab verification', () => { + const FAKE_ITEM_ID = 'cccccccccccccccc'; + + function loginItem(url: string): Item { + return { + id: FAKE_ITEM_ID, + title: 'Example', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + core: { type: 'login', username: 'alice', password: 'hunter2', url }, + sections: [], + attachments: [], + field_history: {}, + }; + } + + function primeUnlocked(state: RouterState): void { + // Provide a fake handle + githost so the handler's "vault_locked" guard + // passes — values don't matter because vault is mocked. + vi.mocked(session.getCurrent).mockReturnValue({ free: () => {} } as never); + state.gitHost = {} as never; + } + + beforeEach(() => { + vi.mocked(session.getCurrent).mockReset(); + vi.mocked(vault.fetchAndDecryptItem).mockReset(); + (chrome.tabs.get as ReturnType).mockReset(); + (chrome.tabs.sendMessage as ReturnType).mockReset(); + }); + + it('returns tab_navigated when captured tab hostname differs from current', async () => { + const state = makeState(); + primeUnlocked(state); + // chrome.tabs.get returns a tab that has navigated to a DIFFERENT host. + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://evil.example/landing', + }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'tab_navigated' }); + // We must NOT have attempted to deliver credentials. + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('returns origin_mismatch when item hostname differs from current tab', async () => { + const state = makeState(); + primeUnlocked(state); + // Tab is still on example.com (matches capturedUrl) … + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + // … but the item we'd fill belongs to github.com. + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://github.com/login'), + ); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: false, error: 'origin_mismatch' }); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalled(); + }); + + it('forwards fill_credentials with expectedHost when all checks pass', async () => { + const state = makeState(); + primeUnlocked(state); + (chrome.tabs.get as ReturnType).mockResolvedValue({ + id: 42, + url: 'https://example.com/login', + }); + vi.mocked(vault.fetchAndDecryptItem).mockResolvedValue( + loginItem('https://example.com/login'), + ); + (chrome.tabs.sendMessage as ReturnType).mockResolvedValue({ ok: true }); + + const res = await route( + { + type: 'fill_credentials', + id: FAKE_ITEM_ID, + capturedTabId: 42, + capturedUrl: 'https://example.com/login', + }, + state, + makePopupSender(), + ); + expect(res).toEqual({ ok: true }); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(42, { + type: 'fill_credentials', + username: 'alice', + password: 'hunter2', + expectedHost: 'example.com', + }); + }); +}); + +// --- save_setup exception scope: setup tab is ONLY allowed save_setup --- + +describe('save_setup exception scope', () => { + it('rejects fill_credentials from the setup tab (setup can only save_setup)', async () => { + const state = makeState(); + const res = await route( + { + type: 'fill_credentials', + id: 'cccccccccccccccc', + capturedTabId: 42, + capturedUrl: 'https://example.com/', + }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); + +// --- isContent rejects unknown sender.id --- + +describe('isContent sender.id guard', () => { + it('rejects content-shaped sender whose id is not the extension id', async () => { + const state = makeState(); + const sender: chrome.runtime.MessageSender = { + tab: { id: 42, url: 'https://example.com/' } as chrome.tabs.Tab, + frameId: 0, + id: 'some-other-extension', // NOT chrome.runtime.id + }; + const res = await route({ type: 'get_autofill_candidates' }, state, sender); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +});