mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: decrypt PGP and S/MIME messages inside conversations #902
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// #902: the phone conversation view (also used by Mountain View's expanded
|
||||
// messages) rendered the raw message body and never asked the
|
||||
// onRenderEmailBody hook, so a PGP message the plugin decrypts in the single
|
||||
// reader stayed an armored block inside a thread.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
const t = Object.assign((k: string) => k, { raw: (k: string) => k, rich: (k: string) => k });
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => t,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
|
||||
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
|
||||
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
|
||||
|
||||
const ARMORED = '-----BEGIN PGP MESSAGE-----\nhQEMA1234\n-----END PGP MESSAGE-----';
|
||||
|
||||
const pgpEmail = (id: string, receivedAt: string) => ({
|
||||
id, blobId: `blob-${id}`, threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: false },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ email: 'me@example.org' }],
|
||||
subject: 'Secret', receivedAt, size: 1, preview: '-----BEGIN PGP MESSAGE-----', hasAttachment: false,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
|
||||
bodyValues: { '1': { value: ARMORED } },
|
||||
});
|
||||
|
||||
const renderThread = () => {
|
||||
const newer = pgpEmail('new', '2026-10-02T10:00:00Z');
|
||||
const older = pgpEmail('old', '2026-10-01T10:00:00Z');
|
||||
return render(
|
||||
<ThreadConversationView
|
||||
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
|
||||
emails={[newer, older] as never}
|
||||
onBack={() => {}}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
describe('conversation view runs the onRenderEmailBody hook', () => {
|
||||
it('shows the plain text a plugin decrypted for each unfolded message', async () => {
|
||||
const handler = vi.fn(async (_body: unknown, ctx: { id: string }) => ({
|
||||
html: '', text: `Decrypted ${ctx.id}`, attachments: [], handledBy: 'pgp',
|
||||
}));
|
||||
renderHooks.onRenderEmailBody.register('pgp', handler as never);
|
||||
|
||||
renderThread();
|
||||
|
||||
expect(await screen.findByText('Decrypted new')).toBeTruthy();
|
||||
expect(await screen.findByText('Decrypted old')).toBeTruthy();
|
||||
expect(screen.queryByText(/BEGIN PGP MESSAGE/)).toBeNull();
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ id: 'new', blobId: 'blob-new' }),
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it('puts decrypted HTML into the sandboxed body frame, sanitized', async () => {
|
||||
renderHooks.onRenderEmailBody.register('pgp', (async () => ({
|
||||
html: '<p>Decrypted html body</p><script>alert(1)</script>', text: '', attachments: [], handledBy: 'pgp',
|
||||
})) as never);
|
||||
|
||||
const { container } = renderThread();
|
||||
|
||||
await waitFor(() => {
|
||||
const srcdocs = [...container.querySelectorAll('iframe')].map((f) => f.getAttribute('srcdoc') ?? '');
|
||||
expect(srcdocs).toHaveLength(2);
|
||||
for (const doc of srcdocs) {
|
||||
expect(doc).toContain('Decrypted html body');
|
||||
expect(doc).not.toContain('alert(1)');
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('keeps the original body when no plugin handles the message', async () => {
|
||||
renderHooks.onRenderEmailBody.register('pgp', (async (body: unknown) => body) as never);
|
||||
|
||||
renderThread();
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getAllByText(/BEGIN PGP MESSAGE/).length).toBe(2);
|
||||
}, 30_000);
|
||||
|
||||
it('decrypts again when a plugin asks for a re-render', async () => {
|
||||
let unlocked = false;
|
||||
renderHooks.onRenderEmailBody.register('pgp', (async () => (unlocked
|
||||
? { html: '', text: 'Unlocked body', attachments: [], handledBy: 'pgp' }
|
||||
: { html: '', text: '', attachments: [] })) as never);
|
||||
|
||||
renderThread();
|
||||
await act(async () => {});
|
||||
expect(screen.queryByText('Unlocked body')).toBeNull();
|
||||
|
||||
unlocked = true;
|
||||
act(() => { window.dispatchEvent(new Event('plugin:rerender-email')); });
|
||||
expect((await screen.findAllByText('Unlocked body')).length).toBe(2);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
|
||||
import { VerificationCodeChip } from "./verification-code-chip";
|
||||
@@ -46,6 +46,7 @@ import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
|
||||
|
||||
@@ -515,6 +516,8 @@ export function ConversationEmailBody({
|
||||
const [hasBlockedContent, setHasBlockedContent] = useState(false);
|
||||
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
|
||||
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
||||
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
|
||||
const pluginBody = usePluginRenderedBody(email);
|
||||
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
|
||||
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
|
||||
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
|
||||
@@ -575,6 +578,30 @@ export function ConversationEmailBody({
|
||||
const emailContent = useMemo(() => {
|
||||
if (!email) return { html: "", isHtml: false };
|
||||
|
||||
// Plugin-rendered bodies get the reader's sanitizer and remote-content
|
||||
// blocking, like any other mail body (#797).
|
||||
if (pluginBody.html) {
|
||||
const withCids = pluginBody.html.replace(
|
||||
/\bcid:([^"'\s)]+)/gi,
|
||||
(_match, cidRef) => pluginBody.cidUrls[cidRef] || cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
|
||||
);
|
||||
const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal);
|
||||
if (blockedExternalContent) {
|
||||
if (!hasBlockedContent) setHasBlockedContent(true);
|
||||
return { html: collapseBlockedImageContainers(html), isHtml: true };
|
||||
}
|
||||
return { html, isHtml: true };
|
||||
}
|
||||
if (pluginBody.text) {
|
||||
return {
|
||||
html: collapsePlainTextQuotes(plainTextToSafeHtml(pluginBody.text, 'text-primary hover:underline'), {
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
}),
|
||||
isHtml: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (email.bodyValues) {
|
||||
let htmlContent = getRenderableHtmlBody(email);
|
||||
|
||||
@@ -651,7 +678,7 @@ export function ConversationEmailBody({
|
||||
// `hasBlockedContent` is only read to skip a redundant setState; as a
|
||||
// dependency it would re-run the sanitizer once the banner shows.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, allowExternal, cidBlobUrls, t]);
|
||||
}, [email, allowExternal, cidBlobUrls, pluginBody, t]);
|
||||
|
||||
// Parts the body embeds via cid: stay out of the attachment row while the
|
||||
// user hides inline images - the desktop viewer's rule, shared through
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { renderHooks } from "@/lib/plugin-hooks";
|
||||
import { toInertBlob } from "@/lib/file-preview";
|
||||
import { debug } from "@/lib/debug";
|
||||
|
||||
export interface PluginRenderedBody {
|
||||
/** HTML a crypto plugin (PGP, S/MIME) rendered for the message, unsanitized. */
|
||||
html: string | null;
|
||||
/** Plain text the plugin rendered, when it gave no HTML. */
|
||||
text: string | null;
|
||||
/** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
|
||||
cidUrls: Record<string, string>;
|
||||
}
|
||||
|
||||
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
|
||||
|
||||
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
|
||||
try {
|
||||
const comma = dataUrl.indexOf(',');
|
||||
if (comma < 0) return null;
|
||||
const meta = dataUrl.slice(0, comma);
|
||||
const data = dataUrl.slice(comma + 1);
|
||||
if (meta.includes(';base64')) {
|
||||
const bin = atob(data);
|
||||
const u8 = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||||
return u8;
|
||||
}
|
||||
return new TextEncoder().encode(decodeURIComponent(data));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the onRenderEmailBody hook for a replacement body, the way the single
|
||||
* reader does, so a message a PGP or S/MIME plugin decrypts also shows
|
||||
* decrypted inside a conversation (#902). Re-runs when a plugin calls
|
||||
* api.ui.rerenderEmail, e.g. after the user unlocked a key.
|
||||
*/
|
||||
export function usePluginRenderedBody(email: Email): PluginRenderedBody {
|
||||
const [body, setBody] = useState<PluginRenderedBody>(EMPTY);
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setNonce((n) => n + 1);
|
||||
window.addEventListener('plugin:rerender-email', bump);
|
||||
return () => window.removeEventListener('plugin:rerender-email', bump);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderHooks.onRenderEmailBody.size === 0) {
|
||||
setBody(EMPTY);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const objectUrls: string[] = [];
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type'];
|
||||
const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType;
|
||||
const result = await renderHooks.onRenderEmailBody.transform(
|
||||
{ html: '', text: '', attachments: [] as unknown[] },
|
||||
{
|
||||
id: email.id,
|
||||
contentType,
|
||||
bodyStructure: email.bodyStructure,
|
||||
bodyValues: email.bodyValues,
|
||||
attachments: email.attachments,
|
||||
blobId: email.blobId,
|
||||
from: email.from,
|
||||
},
|
||||
) as {
|
||||
html?: string;
|
||||
text?: string;
|
||||
attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
|
||||
handledBy?: string;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (!result || !result.handledBy) {
|
||||
setBody(EMPTY);
|
||||
return;
|
||||
}
|
||||
const cidUrls: Record<string, string> = {};
|
||||
for (const att of Array.isArray(result.attachments) ? result.attachments : []) {
|
||||
if (!att.cid || !att.dataUrl) continue;
|
||||
const bytes = dataUrlToBytes(att.dataUrl);
|
||||
if (!bytes) continue;
|
||||
// Same re-typing as fetched cid parts before they get a blob: URL
|
||||
// in our origin (GHSA-xvjh-v9c6-qcvc).
|
||||
const url = URL.createObjectURL(toInertBlob(new Blob([bytes as BlobPart], { type: att.type || 'application/octet-stream' })));
|
||||
objectUrls.push(url);
|
||||
cidUrls[att.cid.replace(/^<|>$/g, '')] = url;
|
||||
}
|
||||
setBody({
|
||||
html: typeof result.html === 'string' && result.html ? result.html : null,
|
||||
text: typeof result.text === 'string' && result.text ? result.text : null,
|
||||
cidUrls,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
debug.error('onRenderEmailBody hook failed:', err);
|
||||
setBody(EMPTY);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [email, nonce]);
|
||||
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user