fix: keep an unquoted cid: image from swallowing the rest of the message

This commit is contained in:
Linus Rath
2026-10-26 11:22:10 +01:00
parent 6014142358
commit 3f2f27321c
4 changed files with 40 additions and 31 deletions
+4 -15
View File
@@ -4,9 +4,9 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal
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, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
@@ -2492,12 +2492,7 @@ export function EmailViewer({
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
if (email.attachments) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => {
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
htmlContent = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL);
}
// Sanitize (no dark mode color transforms - emails render true-to-life
@@ -2594,13 +2589,7 @@ export function EmailViewer({
};
};
if (pluginRenderedHtml) {
const htmlWithCidUrls = pluginRenderedHtml.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => {
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
return renderHtml(htmlWithCidUrls);
return renderHtml(rewriteCidReferences(pluginRenderedHtml, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL));
}
if (pluginRenderedText) {
return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false };
+4 -12
View File
@@ -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, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_SANITIZE_CONFIG, TRANSPARENT_BLOCKED_PIXEL, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import { stripBidiControls } from "@/lib/bidi";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -11,7 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
@@ -617,10 +617,7 @@ export function ConversationEmailBody({
// 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 withCids = rewriteCidReferences(pluginBody.html, { ...cidBlobUrls, ...pluginBody.cidUrls }, TRANSPARENT_BLOCKED_PIXEL);
const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal);
if (blockedExternalContent) {
if (!hasBlockedContent) setHasBlockedContent(true);
@@ -645,12 +642,7 @@ export function ConversationEmailBody({
// Replace cid: references with authenticated blob URLs (fetched via useEffect)
// This prevents browser auth dialogs that occur when loading raw JMAP download URLs
if (email.attachments) {
htmlContent = htmlContent.replace(
/\bcid:([^"'\s)]+)/gi,
(_match, cidRef) => {
return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
);
htmlContent = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL);
}
let blockedExternalContent = false;
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { collectReferencedCids, isEmbeddedInBody } from '@/lib/attachment-visibility';
import { collectReferencedCids, isEmbeddedInBody, rewriteCidReferences } from '@/lib/attachment-visibility';
describe('collectReferencedCids', () => {
it('finds cid: references wherever the body uses them', () => {
@@ -72,3 +72,20 @@ describe('isEmbeddedInBody', () => {
expect(isEmbeddedInBody({ cid: 'logoImage', type: 'application/octet-stream' }, none)).toBe(false);
});
});
describe('rewriteCidReferences', () => {
it('swaps a cid: reference for its URL and an unknown one for the placeholder', () => {
expect(rewriteCidReferences('<img src="cid:logo"><img src="cid:gone">', { logo: 'blob:x/1' }, 'data:,p'))
.toBe('<img src="blob:x/1"><img src="data:,p">');
});
it('ends an unquoted reference at the tag instead of swallowing the markup after it', () => {
const html = '<img src=cid:logo><p>Hello</p>';
expect(rewriteCidReferences(html, { logo: 'blob:x/1' }, 'data:,p')).toBe('<img src=blob:x/1><p>Hello</p>');
expect(collectReferencedCids(html)).toEqual(new Set(['logo']));
});
it('never resolves a reference through the object prototype', () => {
expect(rewriteCidReferences('<img src="cid:constructor">', {}, 'data:,p')).toBe('<img src="data:,p">');
});
});
+14 -3
View File
@@ -14,10 +14,21 @@
*/
/**
* The exact pattern the viewers rewrite to blob URLs, so "referenced" here
* means "rendered" and a chip is only ever hidden for a part the body shows.
* The exact pattern the viewers rewrite to blob URLs (rewriteCidReferences),
* so "referenced" here means "rendered" and a chip is only ever hidden for a
* part the body shows. A reference ends at `<`/`>` too: an unquoted
* `<img src=cid:logo>` would otherwise take the rest of the markup with it.
*/
const CID_REFERENCE = /\bcid:([^"'\s)]+)/gi;
const CID_REFERENCE = /\bcid:([^"'\s)<>]+)/gi;
/**
* Swaps every `cid:` reference in a raw HTML body for the URL fetched for that
* part, or `fallback` when there is none.
*/
export function rewriteCidReferences(html: string, urls: Readonly<Record<string, string>>, fallback: string): string {
return html.replace(CID_REFERENCE, (_match, cid: string) =>
(Object.prototype.hasOwnProperty.call(urls, cid) ? urls[cid] : fallback));
}
const EMPTY: ReadonlySet<string> = new Set();