diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..dcf2bc8e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# Bulwark Webmail – Funding configuration +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +github: [bulwarkmail] diff --git a/README.md b/README.md index ff7b4b1f..1ad8cd9a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.10-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.11-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) 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 d01f4b1e..8d9fec30 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -25,6 +25,7 @@ import { sidebarAppHooks, } from './plugin-hooks'; import { toast as appToast } from '@/stores/toast-store'; +import { useAuthStore } from '@/stores/auth-store'; // ─── Permission helpers ────────────────────────────────────── @@ -128,6 +129,9 @@ export interface PluginAPI { info: (message: string) => void; warning: (message: string) => void; }; + http: { + post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>; + }; storage: ReturnType; log: ReturnType; admin: { @@ -640,6 +644,32 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { warning: (message: string) => appToast.warning(message), }, + http: { + post: async (path: string, body: Record) => { + requirePermission(plugin, 'http:post'); + 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' }; + if (client) { + headers['Authorization'] = client.getAuthHeader(); + headers['X-JMAP-Username'] = client.getUsername(); + } + const res = await fetch(url.pathname + url.search, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, data }; + }, + }, + storage: createPluginStorage(plugin.id), log: createPluginLogger(plugin.id), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index e53ee087..804afeae 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -402,6 +402,7 @@ export const ALL_PERMISSIONS = [ 'settings:read', 'settings:write', 'security:read', 'auth:observe', + 'http:post', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',