mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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?:/);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user