Files
webmail/lib/__tests__/plugin-http-post-denied-paths.test.ts
T
Linus Rath 6d2255ad0d fix: keep plugin http.post away from credentialed and admin routes
`http.post` attaches the user's Authorization header to any same-origin
path the plugin's own manifest lists in `apiPostPaths`. A plugin with
only `http:post` could list the JMAP passthrough and get full JMAP
access, or reach `/api/admin/*`, `/api/settings` and the other
credentialed routes. The host now refuses those paths whatever the
manifest says, checked on the decoded path the router sees. Plugins
keep reaching their own sidecar routes.
2026-09-26 15:01:42 +02:00

67 lines
2.2 KiB
TypeScript

import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import { useAuthStore } from '@/stores/auth-store';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
// http.post attaches the user's Authorization header to a same-origin path
// the plugin's own manifest allowlists. A manifest listing the JMAP
// passthrough or /api/admin/ got full JMAP access (or, with an admin cookie
// present, the admin API) out of a plain http:post grant.
function plugin(apiPostPaths: string[]): InstalledPlugin {
return {
id: 'p1',
name: 'Test plugin',
version: '1.0.0',
author: 'test',
description: '',
type: 'hook',
permissions: ['http:post'],
grantedPermissions: ['http:post'],
entrypoint: 'index.js',
enabled: true,
status: 'running',
settings: {},
apiPostPaths,
};
}
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchSpy);
useAuthStore.setState({
client: { getAuthHeader: () => 'Basic dGVzdA==', getUsername: () => 'tester' } as never,
});
});
afterEach(() => {
vi.unstubAllGlobals();
useAuthStore.setState({ client: null });
});
describe('plugin http.post denied paths', () => {
it.each([
['/api/account/stalwart/jmap', ['/api/account/stalwart/jmap']],
['/api/admin/config', ['/api/admin/']],
['/api/settings', ['/api/settings']],
['/api/auth/session', ['/api/']],
['/api/%61dmin/config', ['/api/%61dmin/']],
['/api//admin/config', ['/api/']],
])('refuses %s even when the manifest lists it', async (path, allow) => {
await expect(dispatchApiCall(plugin(allow), 'http.post', [path, {}])).rejects.toThrow(/not available to plugins/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('still reaches a plugin sidecar route', async () => {
await dispatchApiCall(plugin(['/api/translate']), 'http.post', ['/api/translate', { text: 'hi' }]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});