mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: keep plugin POSTs off the plugin proxy, secrets, agenda, ical and translate routes
This commit is contained in:
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user