mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: keep an unquoted cid: image from swallowing the rest of the message
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user