diff --git a/lib/__tests__/plugin-host-api-third-party.test.ts b/lib/__tests__/plugin-host-api-third-party.test.ts new file mode 100644 index 000000000..7e626fd41 --- /dev/null +++ b/lib/__tests__/plugin-host-api-third-party.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import { setNoThirdPartyTraffic } from '../third-party-mode'; + +// #903: the host API's own locks, behind the loader that already refuses +// plugins declaring network access: api.http.fetch and the WKD key lookup +// reach nothing outside while third-party traffic is off. + +vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'net', name: 'Net', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + ...overrides, + }; +} + +const fetchSpy = vi.fn(async () => new Response('{}', { headers: { 'content-type': 'application/json' } })); +beforeEach(() => { + fetchSpy.mockClear(); + vi.stubGlobal('fetch', fetchSpy); +}); +afterEach(() => { + vi.unstubAllGlobals(); + setNoThirdPartyTraffic(false); +}); + +describe('api.http.fetch', () => { + const fetcher = plugin({ permissions: ['http:fetch'], grantedPermissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] }); + + it('is refused with no third-party traffic, even for a plugin that got started', async () => { + setNoThirdPartyTraffic(true); + await expect(dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x'])).rejects.toThrow(/Outside requests are off/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('reaches the declared origin with the mode off', async () => { + await dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x']).catch(() => undefined); + expect(String((fetchSpy.mock.calls[0] as unknown[] | undefined)?.[0])).toMatch(/^https:\/\/api\.vendor\.example\/x/); + }); +}); + +describe('api.crypto.getPublicKeyFromWKD', () => { + const pgp = plugin({ permissions: ['crypto:full'], grantedPermissions: ['crypto:full'] }); + + it('asks nobody with no third-party traffic', async () => { + setNoThirdPartyTraffic(true); + expect(await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true })).toMatchObject({ status: 'ERROR' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('asks the recipient\'s domain with the mode off', async () => { + fetchSpy.mockImplementationOnce(async () => new Response('', { status: 404 })); + await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true }); + expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//); + }); +});