From 685ce7f46974582e4ceb0755a292acf455172874 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:04:45 +0200 Subject: [PATCH] fix: keep plugin POSTs off the plugin proxy, secrets, agenda, ical and translate routes --- lib/__tests__/plugin-post-denylist.test.ts | 48 ++++++++++++++++++++++ lib/extension-origins.ts | 3 ++ lib/plugin-sandbox/host-api.ts | 13 +++++- 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/plugin-post-denylist.test.ts diff --git a/lib/__tests__/plugin-post-denylist.test.ts b/lib/__tests__/plugin-post-denylist.test.ts new file mode 100644 index 000000000..5d0fad219 --- /dev/null +++ b/lib/__tests__/plugin-post-denylist.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sanitizeApiPostPaths } from '@/lib/extension-origins'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +// host-api pulls in the whole store layer; only the pure gate is used here. +import { isPluginPostDenied } from '@/lib/plugin-sandbox/host-api'; + +// A plugin with `http:post` may POST to the same-origin routes its manifest +// lists, with the user's cookies. The denylist kept it off the passthrough, +// admin and sign-in routes but not off the plugin proxy and secrets routes: +// `/api/plugin-proxy?plugin=&credential=` injects another +// plugin's connected account or admin key into a request and hands the +// answer back, and `/api/plugin-secrets` re-points or deletes them. The +// agenda, fetch-ical and translate routes gave data and reach the plugin's +// own permissions would not. A manifest entry of the bare `/api/` prefix +// also passed validation and allowed every route at once. + +describe('routes no plugin may POST to', () => { + it.each([ + '/api/plugin-proxy', + '/api/plugin-secrets', + '/api/calendar-agenda', + '/api/fetch-ical', + '/api/translate', + '/api/account-archive', + '/api/%70lugin-proxy', + '/api//plugin-secrets', + '/api/account/stalwart/jmap', + ])('%s is denied', (path) => { + expect(isPluginPostDenied(path)).toBe(true); + }); + + it('leaves a plugin sidecar route alone', () => { + expect(isPluginPostDenied('/api/my-plugin/webhook')).toBe(false); + }); +}); + +describe('apiPostPaths manifest entries', () => { + it('rejects the bare API prefix and keeps named routes', () => { + expect(sanitizeApiPostPaths(['/api/', '/api//x', '/api/my-plugin/', '/api/my-plugin/hook'])).toEqual([ + '/api/my-plugin/', + '/api/my-plugin/hook', + ]); + }); +}); diff --git a/lib/extension-origins.ts b/lib/extension-origins.ts index a30299ac5..58a598980 100644 --- a/lib/extension-origins.ts +++ b/lib/extension-origins.ts @@ -61,6 +61,9 @@ export function isValidApiPostPath(path: unknown): path is string { if (typeof path !== 'string') return false; if (path.length === 0 || path.length > 200) return false; if (!path.startsWith('/api/')) return false; + // A route, not the whole API: `/api/` or `/api//x` would name every + // endpoint at once, and the consent screen could not say which. + if (!/^\/api\/[A-Za-z0-9._-]+/.test(path)) return false; if (path.includes('..')) return false; if (/[\s'"`;,()?#]/.test(path)) return false; if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false; diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 12f7127ab..eb9331e3a 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -427,16 +427,27 @@ function isApiPostPathAllowed(path: string, allowlist: readonly string[]): boole */ const PLUGIN_POST_DENIED_ROUTES = new Set([ 'account', + 'account-archive', 'admin', 'auth', 'caldav', + // The user's agenda, a server-side fetch of any URL, and a third-party + // relay: each is data or reach a plugin gets only through its own + // permission-gated API, not through a POST with the user's cookies. + 'calendar-agenda', 'dev-jmap', + 'fetch-ical', 'plugin-approval-status', + // Another plugin's connected accounts and admin keys: the proxy injects + // the credential named in the query string, whichever plugin asks. + 'plugin-proxy', + 'plugin-secrets', 'plugins', 'push', 'settings', 'setup', 'system', + 'translate', 'webdav', 'wopi', ]); @@ -445,7 +456,7 @@ const PLUGIN_POST_DENIED_ROUTES = new Set([ * Checked on the path the router will see: decoded once and with repeated * slashes collapsed, so `/api/%61dmin/...` cannot pass for something else. */ -function isPluginPostDenied(pathname: string): boolean { +export function isPluginPostDenied(pathname: string): boolean { let routePath: string; try { routePath = decodeURIComponent(pathname);