From 58968a1cbf112e41f67f31617a965de0c7d624ee Mon Sep 17 00:00:00 2001 From: chrilep <162469080+chrilep@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:49:23 +0200 Subject: [PATCH 1/5] updated to show latest version in badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 67a0d622bc6c5c8d49bdc050e5a2e326a1de0c61 Mon Sep 17 00:00:00 2001 From: Niklas Voss Date: Tue, 31 Mar 2026 08:37:49 +0200 Subject: [PATCH 2/5] feat: add auth:read permission and ability to retrieve auth headers --- lib/plugin-api.ts | 16 ++++++++++++++++ lib/plugin-types.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index d01f4b1e..3c0411b9 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; }; + auth: { + getHeaders: () => Record; + }; storage: ReturnType; log: ReturnType; admin: { @@ -640,6 +644,18 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { warning: (message: string) => appToast.warning(message), }, + auth: { + getHeaders: (): Record => { + requirePermission(plugin, 'auth:read'); + const { client } = useAuthStore.getState(); + if (!client) return {}; + return { + 'Authorization': client.getAuthHeader(), + 'X-JMAP-Username': client.getUsername(), + }; + }, + }, + storage: createPluginStorage(plugin.id), log: createPluginLogger(plugin.id), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index e53ee087..aebdfb90 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -401,7 +401,7 @@ export const ALL_PERMISSIONS = [ 'vacation:read', 'vacation:write', 'settings:read', 'settings:write', 'security:read', - 'auth:observe', + 'auth:observe', 'auth:read', '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', From 2734fa08b7f2388d99ee6d289795c7a67971ae38 Mon Sep 17 00:00:00 2001 From: Niklas Voss Date: Tue, 31 Mar 2026 20:32:34 +0200 Subject: [PATCH 3/5] feat: instead of exposing auth headers, offer a http proxy api for plugins --- lib/plugin-api.ts | 30 ++++++++++++++++++++---------- lib/plugin-types.ts | 3 ++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 3c0411b9..f1293360 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -129,8 +129,8 @@ export interface PluginAPI { info: (message: string) => void; warning: (message: string) => void; }; - auth: { - getHeaders: () => Record; + http: { + post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>; }; storage: ReturnType; log: ReturnType; @@ -644,15 +644,25 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { warning: (message: string) => appToast.warning(message), }, - auth: { - getHeaders: (): Record => { - requirePermission(plugin, 'auth:read'); + 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 /'); + } const { client } = useAuthStore.getState(); - if (!client) return {}; - return { - 'Authorization': client.getAuthHeader(), - 'X-JMAP-Username': client.getUsername(), - }; + const headers: Record = { 'Content-Type': 'application/json' }; + if (client) { + headers['Authorization'] = client.getAuthHeader(); + headers['X-JMAP-Username'] = client.getUsername(); + } + const res = await fetch(path, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, data }; }, }, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index aebdfb90..804afeae 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -401,7 +401,8 @@ export const ALL_PERMISSIONS = [ 'vacation:read', 'vacation:write', 'settings:read', 'settings:write', 'security:read', - 'auth:observe', 'auth: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', From 52326326e2f6be920e957f58f4e41dd10c88f20d Mon Sep 17 00:00:00 2001 From: Niklas Voss Date: Wed, 1 Apr 2026 08:25:22 +0200 Subject: [PATCH 4/5] 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), From e3560d9cb46f8af23caafe49823f060ec3bb272c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 2 Apr 2026 00:37:58 +0200 Subject: [PATCH 5/5] Add funding configuration for Bulwark Webmail --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml 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]