fix: block remote content in the mobile conversation view

The conversation view used on phones built its own sanitizer hook that
only caught `<img src>` starting with a lowercase scheme and inline
style `url()`, and its iframe CSP always allowed http(s) images. With
remote content blocked (the default `ask` policy) uppercase schemes,
srcset, `<source>`, video posters and background attributes still
loaded, so trackers fired when a phone user opened a thread.

The view now uses the desktop viewer's `blockExternalResourcesOnNode`,
and the strict iframe CSP is shared as `emailIframeCsp()` and applied
whenever remote content is blocked, together with `no-referrer`.
This commit is contained in:
Linus Rath
2026-09-26 14:45:20 +02:00
parent f0aa0eedab
commit 809db3b38d
4 changed files with 89 additions and 36 deletions
@@ -0,0 +1,54 @@
// The mobile conversation view built its own sanitizer hook, which caught
// only <img src> and inline style url(), and a CSP that always allowed
// http:/https: images - so with remote content blocked (default policy
// 'ask') srcset, background attributes, posters and <source> still loaded.
import { describe, expect, it, vi } from 'vitest';
import { render } 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 }));
const TRACKERS = ['upper.gif', 'srcset.gif', 'bgattr.gif', 'poster.gif', 'source.gif'];
const HTML = `
<p>hello</p>
<img src="HTTPS://t.example/upper.gif">
<img srcset="https://t.example/srcset.gif 1x">
<table background="https://t.example/bgattr.gif"><tr><td>x</td></tr></table>
<video poster="https://t.example/poster.gif"></video>
<picture><source srcset="https://t.example/source.gif"><img alt="p"></picture>
`;
describe('conversation view honours the remote-content block', () => {
it('emits no live tracker URL and a strict img-src while blocked', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
expect(useSettingsStore.getState().externalContentPolicy).toBe('ask');
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const email = {
id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hello', hasAttachment: false,
htmlBody: [{ partId: '1', type: 'text/html' }], textBody: [], attachments: [],
bodyValues: { '1': { value: HTML } },
};
const { container } = render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [email], latestEmail: email } as never}
emails={[email] as never}
onBack={() => {}}
/>,
);
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
const srcdoc = iframe!.getAttribute('srcdoc') ?? '';
// A live attribute, not the inert data-blocked-* copy the blocker keeps.
const live = TRACKERS.filter((name) => new RegExp(`\\s(src|srcset|background|poster)="[^"]*${name.replace('.', '\\.')}`, 'i').test(srcdoc));
const csp = /Content-Security-Policy" content="([^"]*)"/.exec(srcdoc)?.[1] ?? '';
expect(live).toEqual([]);
expect(csp).not.toMatch(/img-src[^;]*https?:/);
});
});
+5 -14
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, use
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { applyNewTabToAnchor, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
@@ -2290,19 +2290,10 @@ export function EmailViewer({
p.MsoNormal, li.MsoNormal, div.MsoNormal { margin: 0 0 6px; }
` : '';
// Defense-in-depth CSP inside srcDoc. default-src 'none' forbids script
// execution even if the sanitizer ever lets a <script> through.
//
// When external content is blocked, img/media/font are restricted to
// data:/blob: only — this is the network-level backstop for every tracking
// vector, including ones the DOM-walk blocker can't see (CSS escapes,
// <style>-tag url(), @font-face). When the user loads/trusts the sender the
// srcDoc is rebuilt (see emailContent) with the permissive variant so real
// images, web fonts and media load. cid:/inline images are pre-rewritten to
// blob: URLs, so they survive the strict variant.
const iframeCsp = effectiveEmailContent.externalBlocked
? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'"
: "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
// Defense-in-depth CSP inside srcDoc (see emailIframeCsp). When the user
// loads/trusts the sender the srcDoc is rebuilt (see emailContent) with
// the permissive variant so real images, web fonts and media load.
const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked);
return `<!DOCTYPE html>
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+14 -22
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
@@ -359,24 +359,10 @@ function EmailCard({
// Re-apply the data:-URI allowlist DOMPurify skips on media tags.
restrictDataUriResourcesOnNode(node);
if (!allowExternal) {
if (node.tagName === 'IMG') {
const src = node.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) {
node.setAttribute('data-blocked-src', src);
node.removeAttribute('src');
node.setAttribute('alt', '[Image blocked]');
blockedExternalContent = true;
}
}
if (node.hasAttribute('style')) {
const style = node.getAttribute('style');
if (style && /url\s*\(/i.test(style)) {
const cleanStyle = style.replace(/url\s*\([^)]*\)/gi, 'none');
node.setAttribute('style', cleanStyle);
blockedExternalContent = true;
}
}
// The desktop viewer's blocker: srcset, <source>, poster,
// background attributes and escaped url()s as well as img src.
if (!allowExternal && blockExternalResourcesOnNode(node)) {
blockedExternalContent = true;
}
if (node.tagName === 'A') {
@@ -410,7 +396,7 @@ function EmailCard({
let finalHtml = sanitized;
if (blockedExternalContent) {
setHasBlockedContent(true);
if (!hasBlockedContent) setHasBlockedContent(true);
finalHtml = collapseBlockedImageContainers(sanitized);
}
@@ -441,6 +427,9 @@ function EmailCard({
}
return { html: "", isHtml: false };
// `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, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
// Parts the body embeds via cid: stay out of the attachment row while the
@@ -460,11 +449,14 @@ function EmailCard({
const iframeRef = useRef<HTMLIFrameElement>(null);
const emailIframeSrcDoc = useMemo(() => {
if (!emailContent.isHtml || !emailContent.html) return '';
const csp = "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
// Strict while external content is blocked: the network-level backstop
// for whatever the DOM walk above cannot see.
const csp = emailIframeCsp(!allowExternal);
return `<!DOCTYPE html><html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="referrer" content="no-referrer">
<style>
/* overflow-y hidden keeps the scrollHeight measurement below honest. overflow-x
is auto so intrinsically wide content can pan instead of being clipped outright;
@@ -491,7 +483,7 @@ function EmailCard({
td, th { word-break: break-word; padding: 0.5rem; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style></head><body dir="auto">${emailContent.html}</body></html>`;
}, [emailContent.isHtml, emailContent.html]);
}, [emailContent.isHtml, emailContent.html, allowExternal]);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
+16
View File
@@ -77,6 +77,22 @@ export function sanitizeEmailHtmlForIframe(html: string): string {
return sanitizeWithDataUriGuard(html, EMAIL_IFRAME_SANITIZE_CONFIG);
}
/**
* CSP for the srcdoc document a message body renders in. `default-src
* 'none'` forbids script even if the sanitizer ever lets one through.
*
* With external content blocked, img/media/font are limited to data:/blob:
* only - the network-level backstop for every tracking vector, including
* the ones the DOM walk can't see (CSS escapes, `<style>` url(),
* `@font-face`). cid: parts are rewritten to blob: URLs beforehand, so they
* survive the strict variant.
*/
export function emailIframeCsp(externalBlocked: boolean): string {
return externalBlocked
? "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; media-src data: blob:; base-uri 'none'; form-action 'none'; frame-src 'none'"
: "default-src 'none'; img-src data: blob: http: https:; style-src 'unsafe-inline'; font-src data: http: https:; media-src data: blob: http: https:; base-uri 'none'; form-action 'none'; frame-src 'none'";
}
/** Outcome of {@link sanitizeEmailBodyForIframe}. */
export interface IframeBodySanitizeResult {
/** Sanitized HTML, ready for the iframe srcDoc. */