mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-trust-fixes' into feat/desktop-offline
This commit is contained in:
@@ -16,8 +16,16 @@ vi.mock('@/lib/stalwart/credentials', () => ({
|
||||
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 () => []),
|
||||
@@ -31,10 +39,23 @@ async function remove(query: string, headers: Record<string, string> = { 'sec-fe
|
||||
}
|
||||
|
||||
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(() => {
|
||||
@@ -42,23 +63,58 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DELETE /api/plugin-secrets?all=true', () => {
|
||||
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
|
||||
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');
|
||||
expect((await remove(EVICTED)).status).toBe(200);
|
||||
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');
|
||||
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403);
|
||||
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 () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/lib/plugin-secrets/vault';
|
||||
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { clearWipeProof, hasWipeProof, setWipeProof } from '@/lib/plugin-secrets/wipe-proof';
|
||||
|
||||
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const MAX_SECRET_LENGTH = 4096;
|
||||
@@ -52,7 +53,9 @@ export async function GET(request: NextRequest) {
|
||||
const [admin, adminOrigins] = pluginId
|
||||
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
|
||||
: [[], []];
|
||||
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
if (IS_DESKTOP_BUILD && credentials.length > 0) setWipeProof(answer, user);
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,21 +110,28 @@ export async function POST(request: NextRequest) {
|
||||
if (unavailable) return unavailable;
|
||||
throw error;
|
||||
}
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
if (IS_DESKTOP_BUILD) setWipeProof(answer, user);
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
// An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone.
|
||||
// An account dropped earlier, in the desktop app: no session cookie left, so it
|
||||
// takes the proof that account got while it had one (lib/plugin-secrets/wipe-proof.ts).
|
||||
const { searchParams } = request.nextUrl;
|
||||
const owner = searchParams.get('username');
|
||||
const server = searchParams.get('server');
|
||||
if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) {
|
||||
const named = { username: owner, serverUrl: server };
|
||||
if (!hasWipeProof(request, named)) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
|
||||
try {
|
||||
await deleteAllUserCredentials({ username: owner, serverUrl: server });
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
await deleteAllUserCredentials(named);
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
clearWipeProof(answer, named);
|
||||
return answer;
|
||||
} catch (error) {
|
||||
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
|
||||
if (unavailable) return unavailable;
|
||||
@@ -135,7 +145,9 @@ export async function DELETE(request: NextRequest) {
|
||||
if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') {
|
||||
try {
|
||||
await deleteAllUserCredentials(user);
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
const answer = NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
clearWipeProof(answer, user);
|
||||
return answer;
|
||||
} catch (error) {
|
||||
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
|
||||
if (unavailable) return unavailable;
|
||||
|
||||
Reference in New Issue
Block a user