Files
webmail/app/api/plugin-secrets/__tests__/wipe.test.ts
T
Linus Rath a9e33ec2d6 fix: stop a page wiping another account's plugin secrets by naming it in the desktop app
A wipe by name took any username and server with no session behind it. It
now needs a sealed cookie the account got while it was signed in, since a
dropped account has no session cookie left. The old wipe test asserted that
a name alone was enough, which is the behaviour being removed.
2026-11-04 03:15:35 +01:00

127 lines
5.7 KiB
TypeScript

/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Deleting everything of one user, which only makes sense where the server and its
// files are on the device: the desktop app. On an instance the same request is a
// way to delete someone else's connected accounts, so it has to do nothing.
const deleted = vi.hoisted(() => [] as unknown[]);
const session = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (session.signedIn ? { accountName: 'me@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
}));
vi.mock('@/lib/plugin-secrets/known-plugins', () => ({
declaredUserCredential: async () => ({ inject: { type: 'bearer' } }),
}));
vi.mock('@/lib/plugin-secrets/proxy', () => ({
adminCredentialOrigins: async () => [],
adminCredentialStatus: async () => [],
}));
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
userKey: (user: { username: string; serverUrl: string }) => `${Buffer.from(`${user.username}:${user.serverUrl}`).toString('hex')}0000000000000000`.slice(0, 64),
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
deleteUserCredentials: vi.fn(async () => {}),
listUserCredentials: vi.fn(async () => []),
putUserCredential: vi.fn(async () => {}),
}));
async function remove(query: string, headers: Record<string, string> = { 'sec-fetch-site': 'same-origin' }): Promise<Response> {
vi.resetModules();
const { DELETE } = await import('../route');
return DELETE(new NextRequest(`https://app.local/api/plugin-secrets?${query}`, { method: 'DELETE', headers }));
}
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' };
/** The proof cookie an account gets while signed in, as `name=value`. */
async function proofFor(username: string): Promise<string> {
vi.resetModules();
const { setWipeProof } = await import('@/lib/plugin-secrets/wipe-proof');
const { NextResponse } = await import('next/server');
const answer = NextResponse.json({});
setWipeProof(answer, { username, serverUrl: 'https://mail.example.org' });
const cookie = answer.cookies.getAll()[0];
return `${cookie.name}=${cookie.value}`;
}
beforeEach(() => {
deleted.length = 0;
session.signedIn = false;
vi.stubEnv('SESSION_SECRET', 'wipe-test-session-secret-wipe-test-session-secret');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('DELETE /api/plugin-secrets?all=true', () => {
it('in the desktop app takes a dropped account by who it was, with the proof it got while signed in', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const answer = await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
expect(answer.status).toBe(200);
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
expect(answer.headers.get('set-cookie')).toMatch(/Max-Age=0/i);
});
it('in the desktop app won\'t take any name on its own', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(401);
expect(deleted).toEqual([]);
});
it('in the desktop app won\'t take another account\'s proof, or the signed-in account\'s session, for that name', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
const bob = await proofFor('bob');
const aliceName = (await proofFor('alice')).split('=')[0];
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=${bob.split('=').slice(1).join('=')}` })).status).toBe(401);
expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=forged` })).status).toBe(401);
expect(deleted).toEqual([]);
});
it('won\'t take it from another site', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
const cookie = await proofFor('alice');
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site', cookie })).status).toBe(403);
expect(deleted).toEqual([]);
});
it('gives the proof when an account lists what it has, in the desktop app only', async () => {
session.signedIn = true;
const list = async () => {
vi.resetModules();
const vault = await import('@/lib/plugin-secrets/vault');
vi.mocked(vault.listUserCredentials).mockResolvedValueOnce([{ pluginId: 'p' } as never]);
const { GET } = await import('../route');
return GET(new NextRequest('https://app.local/api/plugin-secrets', { headers: SAME_ORIGIN }));
};
expect((await list()).headers.get('set-cookie')).toBeNull();
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await list()).headers.get('set-cookie')).toMatch(/^jmap_psp_/);
});
it('on an instance deletes nobody\'s, with a cookie or without', async () => {
expect((await remove(EVICTED)).status).toBe(401);
session.signedIn = true;
await remove(EVICTED);
expect(deleted).toEqual([]);
await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') });
expect(deleted).toEqual([]);
});
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
expect((await remove('all=true')).status).toBe(200);
expect(deleted).toEqual([{ username: 'me@example.org', serverUrl: 'https://mail.example.org' }]);
});
});