Skip to content

Commit 3a4f684

Browse files
author
Ioan Moldovan
authored
#5011 Fix: Image with CID (Content-ID) not displaying in pgp block (#5024)
* feat: display cid image with base64 data * get rid of img-to-link * fix: content type * fix: ui test * feat: added ui test * fix: ui test
1 parent 70ee3e5 commit 3a4f684

File tree

9 files changed

+174
-80
lines changed

9 files changed

+174
-80
lines changed

extension/chrome/elements/pgp_block_modules/pgp-block-quote-module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export class PgpBlockViewQuoteModule {
1111

1212
public separateQuotedContentAndRenderText = async (decryptedContent: string, isHtml: boolean) => {
1313
if (isHtml) {
14-
const message = $('<div>').html(Xss.htmlSanitizeKeepBasicTags(decryptedContent, 'IMG-TO-LINK')); // xss-sanitized
14+
const message = $('<div>').html(Xss.htmlSanitizeKeepBasicTags(decryptedContent)); // xss-sanitized
1515
let htmlBlockQuoteExists = false;
1616
const shouldBeQuoted: Array<Element> = [];
1717
for (let i = message[0].children.length - 1; i >= 0; i--) {
@@ -75,7 +75,7 @@ export class PgpBlockViewQuoteModule {
7575
'<div id="action_show_quoted_content" data-test="action-show-quoted-content" class="three_dots"><img src="/img/svgs/three-dots.svg" /></div>'
7676
); // xss-direct
7777
const messageHtml = isHtml ? message : Str.escapeTextAsRenderableHtml(message);
78-
pgpBlk.append(`<div class="quoted_content">${Xss.htmlSanitizeKeepBasicTags(messageHtml, 'IMG-TO-LINK')}</div>`); // xss-sanitized
78+
pgpBlk.append(`<div class="quoted_content">${Xss.htmlSanitizeKeepBasicTags(messageHtml)}</div>`); // xss-sanitized
7979
pgpBlk.find('#action_show_quoted_content').on(
8080
'click',
8181
this.view.setHandler(() => {

extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { Xss } from '../../../js/common/platform/xss.js';
1414
import { MsgBlockParser } from '../../../js/common/core/msg-block-parser.js';
1515
import { AcctStore } from '../../../js/common/platform/store/acct-store.js';
1616
import { GmailParser } from '../../../js/common/api/email-provider/gmail/gmail-parser.js';
17-
import { Str } from '../../../js/common/core/common.js';
17+
import { CID_PATTERN, Str } from '../../../js/common/core/common.js';
18+
import DOMPurify from 'dompurify';
1819

1920
export class PgpBlockViewRenderModule {
2021
public doNotSetStateAsReadyYet = false;
@@ -179,16 +180,12 @@ export class PgpBlockViewRenderModule {
179180
if (!isErr) {
180181
// rendering message content
181182
$('.pgp_print_button').show();
182-
const pgpBlock = $('#pgp_block').html(Xss.htmlSanitizeKeepBasicTags(htmlContent, 'IMG-TO-LINK')); // xss-sanitized
183+
$('#pgp_block').html(Xss.htmlSanitizeKeepBasicTags(htmlContent)); // xss-sanitized
183184
Xss.appendRemoteImagesToContainer();
184185
$('#pgp_block .remote_image_container img').on(
185186
'load',
186187
this.view.setHandler(() => this.resizePgpBlockFrame())
187188
);
188-
pgpBlock.find('a.image_src_link').one(
189-
'click',
190-
this.view.setHandler((el, ev) => this.displayImageSrcLinkAsImg(el as HTMLAnchorElement, ev as JQuery.Event<HTMLAnchorElement, null>))
191-
);
192189
} else {
193190
// rendering our own ui
194191
Xss.sanitizeRender('#pgp_block', htmlContent);
@@ -280,8 +277,9 @@ export class PgpBlockViewRenderModule {
280277
} else {
281278
this.renderText('Formatting...');
282279
const decoded = await Mime.decode(decryptedBytes);
280+
let inlineCIDAttachments: Attachment[] = [];
283281
if (typeof decoded.html !== 'undefined') {
284-
decryptedContent = decoded.html;
282+
({ sanitizedHtml: decryptedContent, inlineCIDAttachments } = this.replaceInlineImageCIDs(decoded.html, decoded.attachments));
285283
isHtml = true;
286284
} else if (typeof decoded.text !== 'undefined') {
287285
decryptedContent = decoded.text;
@@ -299,7 +297,7 @@ export class PgpBlockViewRenderModule {
299297
for (const attachment of decoded.attachments) {
300298
if (attachment.isPublicKey()) {
301299
publicKeys.push(attachment.getData().toUtfStr());
302-
} else {
300+
} else if (!inlineCIDAttachments.some(inlineAttachment => inlineAttachment.cid === attachment.cid)) {
303301
renderableAttachments.push(attachment);
304302
}
305303
}
@@ -319,36 +317,46 @@ export class PgpBlockViewRenderModule {
319317
}
320318
};
321319

322-
private displayImageSrcLinkAsImg = (a: HTMLAnchorElement, event: JQuery.Event<HTMLAnchorElement, null>) => {
323-
const img = document.createElement('img');
324-
img.setAttribute('style', a.getAttribute('style') || '');
325-
img.style.background = 'none';
326-
img.style.border = 'none';
327-
img.addEventListener('load', () => this.resizePgpBlockFrame());
328-
if (a.href.startsWith('cid:')) {
329-
// image included in the email
330-
const contentId = a.href.replace(/^cid:/g, '');
331-
const content = this.view.attachmentsModule.includedAttachments.filter(a => a.type.indexOf('image/') === 0 && a.cid === `<${contentId}>`)[0];
332-
if (content) {
333-
img.src = `data:${a.type};base64,${content.getData().toBase64Str()}`;
334-
Xss.replaceElementDANGEROUSLY(a, img.outerHTML); // xss-safe-value - img.outerHTML was built using dom node api
335-
} else {
336-
Xss.replaceElementDANGEROUSLY(a, Xss.escape(`[broken link: ${a.href}]`)); // xss-escaped
320+
/**
321+
* Replaces inline image CID references with base64 encoded data in sanitized HTML
322+
* and returns the sanitized HTML along with the inline CID attachments.
323+
*
324+
* @param html - The original HTML content.
325+
* @param attachments - An array of email attachments.
326+
* @returns An object containing sanitized HTML and an array of inline CID attachments.
327+
*/
328+
private replaceInlineImageCIDs = (html: string, attachments: Attachment[]): { sanitizedHtml: string; inlineCIDAttachments: Attachment[] } => {
329+
// Array to store inline CID attachments
330+
const inlineCIDAttachments: Attachment[] = [];
331+
332+
// Define the hook function for DOMPurify to process image elements after sanitizing attributes
333+
const processImageElements = (node: Element | null) => {
334+
// Ensure the node exists and has a 'src' attribute
335+
if (!node || !('src' in node)) return;
336+
const imageSrc = node.getAttribute('src') as string;
337+
const matches = imageSrc.match(CID_PATTERN);
338+
339+
// Check if the src attribute contains a CID
340+
if (matches && matches[1]) {
341+
const contentId = matches[1];
342+
const contentIdAttachment = attachments.find(attachment => attachment.cid === `<${contentId}>`);
343+
344+
// Replace the src attribute with a base64 encoded string
345+
if (contentIdAttachment) {
346+
inlineCIDAttachments.push(contentIdAttachment);
347+
node.setAttribute('src', `data:${contentIdAttachment.type};base64,${contentIdAttachment.getData().toBase64Str()}`);
348+
}
337349
}
338-
} else if (a.href.startsWith('https://') || a.href.startsWith('http://')) {
339-
// image referenced as url
340-
img.src = a.href;
341-
Xss.replaceElementDANGEROUSLY(a, img.outerHTML); // xss-safe-value - img.outerHTML was built using dom node api
342-
} else if (a.href.startsWith('data:image/')) {
343-
// image directly inlined
344-
img.src = a.href;
345-
Xss.replaceElementDANGEROUSLY(a, img.outerHTML); // xss-safe-value - img.outerHTML was built using dom node api
346-
} else {
347-
Xss.replaceElementDANGEROUSLY(a, Xss.escape(`[broken link: ${a.href}]`)); // xss-escaped
348-
}
349-
event.preventDefault();
350-
event.stopPropagation();
351-
event.stopImmediatePropagation();
350+
};
351+
352+
// Add the DOMPurify hook
353+
DOMPurify.addHook('afterSanitizeAttributes', processImageElements);
354+
355+
// Sanitize the HTML and remove the DOMPurify hooks
356+
const sanitizedHtml = DOMPurify.sanitize(html);
357+
DOMPurify.removeAllHooks();
358+
359+
return { sanitizedHtml, inlineCIDAttachments };
352360
};
353361

354362
private getEncryptedSubjectText = (subject: string, isHtml: boolean) => {

extension/js/common/core/common.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type UrlParams = Dict<UrlParam>;
1212
export type PromiseCancellation = { cancel: boolean };
1313
export type EmailParts = { email: string; name?: string };
1414

15+
export const CID_PATTERN = /^cid:(.+)/;
16+
1517
export class Str {
1618
// ranges are taken from https://stackoverflow.com/a/14824756
1719
// with the '\u0300' -> '\u0370' modification, because from '\u0300' to '\u0370' there are only punctuation marks
@@ -416,3 +418,8 @@ export const asyncSome = async <T>(arr: Array<T>, predicate: (e: T) => Promise<b
416418
export const stringTuple = <T extends string[]>(...data: T): T => {
417419
return data;
418420
};
421+
422+
export const checkValidURL = (url: string): boolean => {
423+
const pattern = /(http|https):\/\/([a-z0-9-]+((\.[a-z0-9-]+)+)?)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@\-\/]))?/;
424+
return pattern.test(url);
425+
};

extension/js/common/core/msg-block-parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class MsgBlockParser {
4646

4747
public static fmtDecryptedAsSanitizedHtmlBlocks = async (
4848
decryptedContent: Uint8Array,
49-
imgHandling: SanitizeImgHandling = 'IMG-TO-LINK'
49+
imgHandling: SanitizeImgHandling = 'IMG-KEEP'
5050
): Promise<SanitizedBlocks> => {
5151
const blocks: MsgBlock[] = [];
5252
let isRichText = false;

extension/js/common/platform/xss.ts

Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
import * as DOMPurify from 'dompurify';
66

7-
import { Str } from '../core/common.js';
7+
import { checkValidURL, CID_PATTERN, Str } from '../core/common.js';
88

9-
export type SanitizeImgHandling = 'IMG-DEL' | 'IMG-KEEP' | 'IMG-TO-LINK' | 'IMG-TO-PLAIN-URL';
9+
export type SanitizeImgHandling = 'IMG-DEL' | 'IMG-KEEP' | 'IMG-TO-PLAIN-TEXT';
1010

1111
/**
1212
* This class is in platform/ folder because most of it depends on platform specific code
@@ -98,13 +98,8 @@ export class Xss {
9898
* @property {string} imgHandling - how should images be treated, with the following options:
9999
* - IMG-DEL: remove images, only leaving text
100100
* - IMG-KEEP: keep images as they are
101-
* - IMG-TO-LINK: transform images to clickable links that display the images inline upon click, as follows:
102-
* from: <img src="there" title="that">
103-
* to: <a href="data:image/..." title="that" class="image_src_link" target="_blank" style="...">show image</a>
104-
* or: <a href="https://..." title="that" class="image_src_link" target="_blank" style="...">show image (remote)</a>
105-
* (when rendered, we add event handler to `.image_src_link` that responds to a click and render the image)
106101
*/
107-
public static htmlSanitizeKeepBasicTags = (dirtyHtml: string, imgHandling: SanitizeImgHandling): string => {
102+
public static htmlSanitizeKeepBasicTags = (dirtyHtml: string, imgHandling: SanitizeImgHandling = 'IMG-KEEP'): string => {
108103
Xss.throwIfNotSupported();
109104
// used whenever untrusted remote content (eg html email) is rendered, but we still want to preserve html
110105
DOMPurify.removeAllHooks();
@@ -131,33 +126,14 @@ export class Xss {
131126
img.remove(); // just skip images
132127
} else if (!src) {
133128
img.remove(); // src that exists but is null is suspicious
134-
} else if (imgHandling === 'IMG-TO-LINK') {
135-
// replace images with a link that points to that image
136-
if (src.startsWith('data:image/')) {
137-
const title = img.getAttribute('title');
138-
img.removeAttribute('src');
139-
const a = document.createElement('a');
140-
a.href = src;
141-
a.className = 'image_src_link';
142-
a.target = '_blank';
143-
a.innerText = title || 'show image';
144-
const heightWidth = `height: ${img.clientHeight ? `${Number(img.clientHeight)}px` : 'auto'}; width: ${
145-
img.clientWidth ? `${Number(img.clientWidth)}px` : 'auto'
146-
};max-width:98%;`;
147-
a.setAttribute(
148-
'style',
149-
`text-decoration: none; background: #FAFAFA; padding: 4px; border: 1px dotted #CACACA; display: inline-block; ${heightWidth}`
150-
);
151-
a.setAttribute('data-test', 'show-inline-image');
152-
Xss.replaceElementDANGEROUSLY(img, a.outerHTML); // xss-safe-value - "a" was build using dom node api
153-
} else {
154-
const remoteImgEl = `<div class="remote_image_container" data-src="${src}" data-test="remote-image-container"><span>Authenticity of this remote image cannot be verified.</span></div>`;
155-
Xss.replaceElementDANGEROUSLY(img, remoteImgEl); // xss-safe-value
156-
}
129+
} else if (imgHandling === 'IMG-KEEP' && checkValidURL(src)) {
130+
// replace remote image with remote_image_container
131+
const remoteImgEl = `<div class="remote_image_container" data-src="${src}" data-test="remote-image-container"><span>Authenticity of this remote image cannot be verified.</span></div>`;
132+
Xss.replaceElementDANGEROUSLY(img, remoteImgEl); // xss-safe-value
157133
}
158134
}
159-
if (node.classList.contains('remote_image_container') && imgHandling === 'IMG-TO-PLAIN-URL') {
160-
Xss.replaceElementDANGEROUSLY(node, node.getAttribute('data-src') ?? ''); // xss-safe-value
135+
if ((node.classList.contains('remote_image_container') || CID_PATTERN.test(node.getAttribute('src') ?? '')) && imgHandling === 'IMG-TO-PLAIN-TEXT') {
136+
Xss.replaceElementDANGEROUSLY(node, node.getAttribute('data-src') ?? node.getAttribute('alt') ?? ''); // xss-safe-value
161137
}
162138
if ('target' in node) {
163139
// open links in new window
@@ -200,7 +176,7 @@ export class Xss {
200176
*/
201177
public static htmlSanitizeAndStripAllTags = (dirtyHtml: string, outputNl: string, trim = true): string => {
202178
Xss.throwIfNotSupported();
203-
let html = Xss.htmlSanitizeKeepBasicTags(dirtyHtml, 'IMG-TO-PLAIN-URL');
179+
let html = Xss.htmlSanitizeKeepBasicTags(dirtyHtml, 'IMG-TO-PLAIN-TEXT');
204180
const random = Str.sloppyRandom(5);
205181
const br = `CU_BR_${random}`;
206182
const blockStart = `CU_BS_${random}`;

test/source/mock/google/exported-messages/message-export-186eed032659ad4f.json

Lines changed: 90 additions & 0 deletions
Large diffs are not rendered by default.

test/source/platform/xss.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact [email protected] */
22

3-
export type SanitizeImgHandling = 'IMG-DEL' | 'IMG-KEEP' | 'IMG-TO-LINK';
3+
export type SanitizeImgHandling = 'IMG-DEL' | 'IMG-KEEP';
44

55
/**
66
* Look at https://github.com/FlowCrypt/flowcrypt-mobile-core/blob/master/TypeScript/source/platform/xss.ts if node implementation is ever needed for tests.

test/source/tests/compose.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2706,10 +2706,6 @@ const sendImgAndVerifyPresentInSentMsg = async (t: AvaContext, browser: BrowserH
27062706
// open a page with the sent msg, investigate img
27072707
const pgpHostPage = await browser.newPage(t, url);
27082708
const pgpBlockPage = await pgpHostPage.getFrame(['pgp_block.htm']);
2709-
await pgpBlockPage.waitAll('.image_src_link');
2710-
expect(await pgpBlockPage.read('.image_src_link')).to.contain('show image');
2711-
await pgpBlockPage.waitAndClick('.image_src_link');
2712-
await pgpBlockPage.waitTillGone('.image_src_link');
27132709
const img = await pgpBlockPage.waitAny('body img');
27142710
expect(await PageRecipe.getElementPropertyJson(img, 'src')).to.eq(imgBase64);
27152711
};

test/source/tests/decrypt.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export const defineDecryptTests = (testVariant: TestVariant, testWithBrowser: Te
6262
await inboxPage.waitAll('iframe');
6363
const pgpBlock = await inboxPage.getFrame(['pgp_block.htm']);
6464
await pgpBlock.waitForContent('@pgp-block-content', 'This message contains inline base64 image');
65-
await pgpBlock.waitAndClick('@show-inline-image');
65+
await pgpBlock.waitAll('#pgp_block img');
66+
await pgpBlock.checkIfImageIsDisplayedCorrectly('#pgp_block img');
6667
await inboxPage.close();
6768
})
6869
);
@@ -679,6 +680,22 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw==
679680
})
680681
);
681682

683+
test(
684+
'decrypt - display email with cid image correctly',
685+
testWithBrowser('compatibility', async (t, browser) => {
686+
const threadId = '186eed032659ad4f';
687+
const acctEmail = '[email protected]';
688+
const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`);
689+
await inboxPage.waitAll('iframe');
690+
const pgpBlock = await inboxPage.getFrame(['pgp_block.htm']);
691+
await pgpBlock.waitForSelTestState('ready');
692+
await pgpBlock.checkIfImageIsDisplayedCorrectly('#pgp_block img');
693+
const replyFrame = await inboxPage.getFrame(['compose.htm']);
694+
await replyFrame.waitAndClick('@action-forward');
695+
await replyFrame.waitForContent('@input-body', 'googlelogo_color_272x92dp.png'); // check if forwarded content contains cid image name
696+
})
697+
);
698+
682699
test(
683700
"decrypt - thunderbird - signedHtml verifyDetached doesn't duplicate PGP key section",
684701
testWithBrowser('compatibility', async (t, browser) => {

0 commit comments

Comments
 (0)