Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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]
|
||||
@@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
http: {
|
||||
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
|
||||
};
|
||||
storage: ReturnType<typeof createPluginStorage>;
|
||||
log: ReturnType<typeof createPluginLogger>;
|
||||
admin: {
|
||||
@@ -640,6 +644,32 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
warning: (message: string) => appToast.warning(message),
|
||||
},
|
||||
|
||||
http: {
|
||||
post: async (path: string, body: Record<string, unknown>) => {
|
||||
requirePermission(plugin, 'http:post');
|
||||
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<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (client) {
|
||||
headers['Authorization'] = client.getAuthHeader();
|
||||
headers['X-JMAP-Username'] = client.getUsername();
|
||||
}
|
||||
const res = await fetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
},
|
||||
},
|
||||
|
||||
storage: createPluginStorage(plugin.id),
|
||||
log: createPluginLogger(plugin.id),
|
||||
|
||||
|
||||
@@ -402,6 +402,7 @@ export const ALL_PERMISSIONS = [
|
||||
'settings:read', 'settings:write',
|
||||
'security: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',
|
||||
|
||||
Reference in New Issue
Block a user