diff --git a/components/email/__tests__/thread-conversation-view-remote-block.test.tsx b/components/email/__tests__/thread-conversation-view-remote-block.test.tsx new file mode 100644 index 000000000..5b4ed7ea5 --- /dev/null +++ b/components/email/__tests__/thread-conversation-view-remote-block.test.tsx @@ -0,0 +1,54 @@ +// The mobile conversation view built its own sanitizer hook, which caught +// only 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 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 = ` +

hello

+ + +
x
+ +p +`; + +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( + {}} + />, + ); + 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?:/); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 686b1526a..8609d379f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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