From 52326326e2f6be920e957f58f4e41dd10c88f20d Mon Sep 17 00:00:00 2001 From: Niklas Voss Date: Wed, 1 Apr 2026 08:25:22 +0200 Subject: [PATCH] fix: check plugin http.post url against origin and add regression tests --- lib/__tests__/plugin-api.test.ts | 43 ++++++++++++++++++++++++++++++++ lib/plugin-api.ts | 10 +++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/plugin-api.test.ts b/lib/__tests__/plugin-api.test.ts index 67f1d6a7..74ee7d90 100644 --- a/lib/__tests__/plugin-api.test.ts +++ b/lib/__tests__/plugin-api.test.ts @@ -148,3 +148,46 @@ describe('toast bridge', () => { expect(api.toast.warning).toBeInstanceOf(Function); }); }); + +describe('http.post path validation', () => { + function makeApi(permissions: string[] = ['http:post']) { + return createPluginAPI(makePlugin({ permissions })); + } + + it('rejects protocol-relative URLs like //evil.example', async () => { + const api = makeApi(); + await expect(api.http.post('//evil.example/collect', {})).rejects.toThrow('must start with /api/'); + }); + + it('rejects absolute URLs to other origins', async () => { + const api = makeApi(); + await expect(api.http.post('https://evil.example/steal', {})).rejects.toThrow('must start with /api/'); + }); + + it('rejects paths not under /api/', async () => { + const api = makeApi(); + await expect(api.http.post('/other/path', {})).rejects.toThrow('must start with /api/'); + }); + + it('rejects paths that use backslash to bypass the check', async () => { + const api = makeApi(); + await expect(api.http.post('/api/\\@evil.example', {})).rejects.toThrow(); + }); + + it('throws without http:post permission', async () => { + const api = makeApi([]); + await expect(api.http.post('/api/jitsi', {})).rejects.toThrow('lacks permission'); + }); + + it('accepts a valid /api/ path', async () => { + const api = makeApi(); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ url: 'https://meet.example.com/room' }), + }); + const result = await api.http.post('/api/jitsi', { eventTitle: 'test' }); + expect(result.ok).toBe(true); + expect(result.data).toEqual({ url: 'https://meet.example.com/room' }); + }); +}); diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index f1293360..8d9fec30 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -647,8 +647,12 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { http: { post: async (path: string, body: Record) => { requirePermission(plugin, 'http:post'); - if (typeof path !== 'string' || !path.startsWith('/')) { - throw new Error('path must be an absolute path starting with /'); + if (typeof path !== 'string' || !path.startsWith('/api/')) { + throw new Error('path must start with /api/'); + } + const url = new URL(path, globalThis.location.origin); + if (url.origin !== globalThis.location.origin) { + throw new Error('path must resolve to the same origin'); } const { client } = useAuthStore.getState(); const headers: Record = { 'Content-Type': 'application/json' }; @@ -656,7 +660,7 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); } - const res = await fetch(path, { + const res = await fetch(url.pathname + url.search, { method: 'POST', headers, body: JSON.stringify(body),