diff --git a/extension/js/common/message-renderer.ts b/extension/js/common/message-renderer.ts index 5b7c1270beb..cf7cf679964 100644 --- a/extension/js/common/message-renderer.ts +++ b/extension/js/common/message-renderer.ts @@ -102,6 +102,9 @@ export class MessageRenderer { if (contentIdAttachment) { inlineCIDAttachments.add(contentIdAttachment); currentNode.setAttribute('src', `data:${contentIdAttachment.type};base64,${contentIdAttachment.getData().toBase64Str()}`); + // a CID-backed inline image must not carry other resource-loading attributes (eg srcset) that + // could reference an external URL and trigger a remote request outside of the consent flow + currentNode.removeAttribute('srcset'); } } }); diff --git a/extension/js/common/platform/xss.ts b/extension/js/common/platform/xss.ts index 20cc682b07d..0c95ba1094f 100644 --- a/extension/js/common/platform/xss.ts +++ b/extension/js/common/platform/xss.ts @@ -196,14 +196,24 @@ export class Xss { if (node.tagName === 'IMG') { const img = node as HTMLImageElement; // Narrow type to HTMLImageElement const src = img.getAttribute('src'); + const srcset = img.getAttribute('srcset'); + // an image is remote when `src` or any `srcset` candidate references an external URL; `srcset` + // (and protocol-relative `//` URLs) must be covered too, otherwise they could be used to trigger + // a remote request that bypasses the remote-image consent flow + let remoteSrc: string | undefined; + if (src && Xss.isRemoteUrl(src)) { + remoteSrc = src; + } else if (srcset) { + remoteSrc = Xss.getRemoteUrlFromSrcset(srcset); + } if (imgHandling === 'IMG-DEL') { img.remove(); // just skip images - } else if (!src) { - img.remove(); // src that exists but is null is suspicious - } else if (imgHandling === 'IMG-KEEP' && checkValidURL(src)) { + } else if (!src && !srcset) { + img.remove(); // an image without any source is suspicious + } else if (imgHandling === 'IMG-KEEP' && remoteSrc) { // replace remote image with remote_image_container const remoteImgEl = ` -
message body`;
+ const clean = Xss.htmlSanitizeKeepBasicTags(dirty, 'IMG-KEEP');
+ const doc = new DOMParser().parseFromString(clean, 'text/html');
+ const imgs = Array.from(doc.querySelectorAll('img'));
+ for (const img of imgs) {
+ if (/^https?:/i.test(img.getAttribute('src') || '') || /^https?:/i.test(img.getAttribute('srcset') || '')) {
+ throw Error(`remote-loading img survived sanitization: ${clean}`);
+ }
+ }
+ const containers = doc.querySelectorAll('.remote_image_container');
+ if (containers.length !== 1) {
+ throw Error(`expected exactly one remote_image_container placeholder but got ${clean}`);
+ }
+ if (containers[0].getAttribute('data-src') !== 'https://attacker.example/track.png') {
+ throw Error(`unexpected data-src "${containers[0].getAttribute('data-src')}" in ${clean}`);
+ }
+ return 'pass';
+})();
+
+BROWSER_UNIT_TEST_NAME(`Xss.htmlSanitizeKeepBasicTags sends multi-candidate remote srcset to consent flow (IMG-KEEP)`);
+(async () => {
+ const dirty = `
message body`;
+ const clean = Xss.htmlSanitizeKeepBasicTags(dirty, 'IMG-KEEP');
+ const doc = new DOMParser().parseFromString(clean, 'text/html');
+ for (const img of Array.from(doc.querySelectorAll('img'))) {
+ if (img.getAttribute('src') || img.getAttribute('srcset')) {
+ throw Error(`remote-loading img survived sanitization: ${clean}`);
+ }
+ }
+ const containers = doc.querySelectorAll('.remote_image_container');
+ if (containers.length !== 1) {
+ throw Error(`expected exactly one remote_image_container placeholder but got ${clean}`);
+ }
+ return 'pass';
+})();
+
+BROWSER_UNIT_TEST_NAME(`Xss.htmlSanitizeKeepBasicTags keeps local data srcset images untouched (IMG-KEEP)`);
+(async () => {
+ const dirty = `
message body`;
+ const text = Xss.htmlUnescape(Xss.htmlSanitizeAndStripAllTags(dirty, '\n', false));
+ if (text.includes('https://attacker.example') || text.includes('srcset')) {
+ throw Error(`remote srcset URL leaked into plain text: ${text}`);
+ }
+ return 'pass';
+})();