fix: keep plugin POSTs off the plugin proxy, secrets, agenda, ical and translate routes

This commit is contained in:
Linus Rath
2026-10-19 01:04:45 +02:00
parent 7763996698
commit 685ce7f469
3 changed files with 63 additions and 1 deletions
@@ -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=<other>&credential=<id>` 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',
]);
});
});
+3
View File
@@ -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;
+12 -1
View File
@@ -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);