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)
|
[](LICENSE)
|
||||||
[](https://discord.gg/tYCujymGrT)
|
[](https://discord.gg/tYCujymGrT)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://ghcr.io/bulwarkmail/webmail)
|
[](https://ghcr.io/bulwarkmail/webmail)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,3 +148,46 @@ describe('toast bridge', () => {
|
|||||||
expect(api.toast.warning).toBeInstanceOf(Function);
|
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,
|
sidebarAppHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
// ─── Permission helpers ──────────────────────────────────────
|
// ─── Permission helpers ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -128,6 +129,9 @@ export interface PluginAPI {
|
|||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
warning: (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>;
|
storage: ReturnType<typeof createPluginStorage>;
|
||||||
log: ReturnType<typeof createPluginLogger>;
|
log: ReturnType<typeof createPluginLogger>;
|
||||||
admin: {
|
admin: {
|
||||||
@@ -640,6 +644,32 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
warning: (message: string) => appToast.warning(message),
|
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),
|
storage: createPluginStorage(plugin.id),
|
||||||
log: createPluginLogger(plugin.id),
|
log: createPluginLogger(plugin.id),
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ export const ALL_PERMISSIONS = [
|
|||||||
'settings:read', 'settings:write',
|
'settings:read', 'settings:write',
|
||||||
'security:read',
|
'security:read',
|
||||||
'auth:observe',
|
'auth:observe',
|
||||||
|
'http:post',
|
||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||||
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
|
||||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
|
|||||||
Reference in New Issue
Block a user