feat: add setting to disable auto-loading external images in emails (#1092)

* feat: add setting to disable auto-loading external images in emails

Adds a privacy setting (default off) that blocks remote images in email
content until the user explicitly loads them per message. Blocked images
are replaced with a placeholder; a banner allows one-click loading.

Closes #1073

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): block remote content with DOMPurify and an allowlist policy

Address review on the blocking logic. The first pass matched quoted
`<img src="http...">` with a regex, which left unquoted src, srcset,
`<source>`, CSS background-image, SVG `<image href>` and entity-encoded
schemes fetching as usual, and replaced only `src` on an element that also
carried `srcset` -- so the browser still had a remote candidate to prefer
while the UI claimed the image was blocked.

Two changes rather than a wider regex:

Sanitising is delegated to DOMPurify, which is already a dependency. The
hard part here is not enumerating attributes but surviving the parser: a
hand-written pass over a DOMParser tree still missed that `<noscript>` is
parsed as markup where scripting is off and as raw text where it is on, so a
`</noscript>` smuggled into an attribute value reopens the document at
render time and revives an `<img>` the cleaner never saw. Elements that
fetch by themselves or change how relative URLs resolve -- base, meta,
script, link, iframe, object, embed, noscript -- are dropped in this mode.
`<style>` is kept so layout survives, with its url(), image-set() and
@import references filtered.

URL classification is an allowlist. Asking "does this look remote?" means
enumerating every disguise -- backslash authorities, tab/newline/control
characters the URL parser strips, CSS escapes, schemes with no slashes --
and losing to the first one not thought of. Asking "can I prove this is
local?" fails closed instead: cid:, data:image/, blob: and relative paths
are kept, everything else is blocked. Relative paths are only safe because
`<base>` is removed, which is what stopped it re-pointing them at a tracker.

The blocked URL is discarded rather than parked in a data-* attribute, so
"the cleaned body contains no remote URL at all" is directly assertable;
restoring images re-renders from the untouched source.

Also: blob: is added to the allowed schemes -- DOMPurify's default list
omits it, and email-parser rewrites cid: attachments into blob: URLs, so
without it every inline image would be stripped along with the trackers.

The policy lives in its own module with its own tests (30 attack vectors,
7 preservation cases); email-parser.js goes back to MIME parsing only. The
per-mail override no longer initialises from the global setting, and the
banner reports the blocked count as the PR description promised.

Refs #1073

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Josh Tsai
2026-07-29 14:28:15 +08:00
committed by GitHub
parent 342fe22e4f
commit e499211197
9 changed files with 334 additions and 8 deletions

View File

@@ -1,14 +1,15 @@
<script setup>
import { ref } from "vue";
import { ref, computed, watch } from "vue";
import { useScopedI18n } from '@/i18n/app'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound, ImageRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { blockRemoteContent } from '../utils/remote-content-policy';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
const { t } = useScopedI18n('components.MailContentRenderer')
@@ -58,6 +59,26 @@ const curAttachments = ref([]);
const attachmentLoding = ref(false);
const showFullscreen = ref(false);
// Per-mail consent, deliberately independent of the global setting: it only
// ever turns true when the user clicks "load images" for this specific mail,
// and resets when a different mail is shown.
const showRemoteImages = ref(false);
watch(() => props.mail.id, () => {
showRemoteImages.value = false;
});
const processedMail = computed(() => {
if (autoLoadRemoteImages.value || showRemoteImages.value) {
return { message: props.mail.message, blocked: 0 };
}
const { html, blocked } = blockRemoteContent(props.mail.message);
return { message: html, blocked };
});
const handleLoadRemoteImages = () => {
showRemoteImages.value = true;
};
const handleDelete = () => {
props.onDelete();
};
@@ -149,17 +170,32 @@ const handleSaveToS3 = async (filename, blob) => {
</template>
{{ t('fullscreen') }}
</n-button>
</n-space>
<!-- 外部资源阻断提示 -->
<n-alert v-if="processedMail.blocked" type="warning" :show-icon="false" :bordered="false"
class="remote-images-banner">
<n-space align="center" justify="space-between">
<span>{{ t('remoteImagesBlocked', { count: processedMail.blocked }) }}</span>
<n-button size="tiny" tertiary type="warning" @click="handleLoadRemoteImages">
<template #icon>
<n-icon :component="ImageRound" />
</template>
{{ t('loadRemoteImages') }}
</n-button>
</n-space>
</n-alert>
<!-- AI 提取信息 -->
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
</div>
</div>
@@ -168,9 +204,9 @@ const handleSaveToS3 = async (filename, blob) => {
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
@@ -215,6 +251,11 @@ const handleSaveToS3 = async (filename, blob) => {
gap: 10px;
}
/* Let the banner's inner space fill the alert so the button sits on the right. */
.remote-images-banner :deep(.n-space) {
width: 100%;
}
.mail-content {
margin-top: 10px;
flex: 1;

View File

@@ -190,6 +190,14 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
"loadRemoteImages": {
"en": "Load Images",
"zh": "加载图片"
},
"remoteImagesBlocked": {
"en": "{count} remote resources blocked to protect your privacy",
"zh": "已阻止 {count} 项外部资源以保护隐私"
},
"reply": {
"en": "Reply",
"zh": "回复"
@@ -2094,6 +2102,10 @@ export const MESSAGE_REGISTRY = {
}
},
"views.common.Appearance": {
"autoLoadRemoteImages": {
"en": "Automatically load external images in mail body",
"zh": "自动加载邮件正文中的外部图片"
},
"autoRefreshInterval": {
"en": "Auto Refresh Interval(Sec)",
"zh": "自动刷新间隔(秒)"

View File

@@ -99,6 +99,7 @@ export const useGlobalState = createGlobalState(
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const useUTCDate = useStorage('useUTCDate', false);
const autoLoadRemoteImages = useStorage('autoLoadRemoteImages', true);
const autoRefresh = useStorage('autoRefresh', false);
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
const userOpenSettings = ref({
@@ -175,6 +176,7 @@ export const useGlobalState = createGlobalState(
globalTabplacement,
useSideMargin,
useUTCDate,
autoLoadRemoteImages,
autoRefresh,
configAutoRefreshInterval,
telegramApp,

View File

@@ -0,0 +1,81 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { blockRemoteContent } from '../remote-content-policy';
const T = 'https://tracker.example/p.png';
function leaks(html) {
const host = document.createElement('div');
host.innerHTML = html;
const f = [];
for (const el of host.querySelectorAll('*')) {
for (const a of el.attributes) {
if (a.name.startsWith('data-blocked-')) continue;
if (/tracker\.example/i.test(a.value)) f.push(`${el.tagName}[${a.name}]`);
}
if (el.tagName === 'STYLE' && /tracker\.example/i.test(el.textContent || '')) f.push('STYLE-text');
}
if (/tracker\.example/i.test(host.innerHTML) && !f.length) f.push('RAW');
return f;
}
const TAB = String.fromCharCode(9);
const NUL = String.fromCharCode(1);
const V = [
['noscript 突破', `<p>hi</p><noscript><b title="</noscript><img src=${T}>"></noscript>`],
['base href', `<base href="https://tracker.example/"><img src="/logo.png">`],
['iframe srcdoc', `<iframe srcdoc="&lt;img src=${T}&gt;"></iframe>`],
['script src', `<script src="${T}"></script>`],
['meta refresh', `<meta http-equiv="refresh" content="0;url=${T}">`],
['link preload', `<link rel="preload" as="image" href="${T}">`],
['link imagesrcset', `<link rel="preload" as="image" imagesrcset="${T} 1x">`],
['frame src', `<frameset><frame src="${T}"></frameset>`],
['style @import 註解', `<style>@import/**/"${T}";</style>`],
['style @import url', `<style>@import url(${T});</style>`],
['style background', `<style>.a{background:url("${T}")}</style><div class="a"></div>`],
['style image-set', `<style>.a{background:image-set('${T}' 1x)}</style>`],
['style 誘餌 url(', `<style>.a{content:"url(";background:url(${T})}</style>`],
['attr image-set', `<div style="background:image-set('${T}' 1x)">x</div>`],
['attr CSS 跳脫', `<div style="background:url(\\68 ttps://tracker.example/p.png)">x</div>`],
['URL 反斜線', `<img src="https:\\\\tracker.example\\p.png">`],
['scheme 無斜線', `<img src="https:tracker.example/p.png">`],
['data:text/html iframe', `<iframe src="data:text/html,&lt;img src=${T}&gt;"></iframe>`],
['quoted src', `<img src="${T}">`],
['unquoted src', `<img src=${T}>`],
['srcset', `<img srcset="${T} 1x">`],
['src+srcset', `<img src="${T}" srcset="https://tracker.example/2x.png 2x">`],
['source srcset', `<picture><source srcset="${T}"><img src="cid:x"></picture>`],
['td background', `<table><tr><td background="${T}">x</td></tr></table>`],
['svg image href', `<svg><image href="${T}"/></svg>`],
['video poster', `<video poster="${T}"></video>`],
['tab 分割 scheme', `<img src="ht${TAB}tps://tracker.example/p.png">`],
['C0 控制字元前綴', `<img src="${NUL}${T}">`],
['entity scheme', `<img src="https&#58;//tracker.example/p.png">`],
['protocol-relative', `<img src="//tracker.example/p.png">`],
];
const KEEP = [
['cid', '<img src="cid:p@x">', 'cid:p@x'],
['data image', '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7">', 'data:image/gif'],
['blob', '<img src="blob:https://app.example/8f2c">', 'blob:'],
['相對路徑', '<img src="/assets/logo.png">', '/assets/logo.png'],
['排版 CSS', '<table><tr><td style="padding:8px;color:#333">hi</td></tr></table>', 'padding:8px'],
['style 區塊排版', '<style>.a{color:red;font-size:14px}</style><p class="a">x</p>', 'font-size:14px'],
['data: 於 CSS', '<div style="background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7)">x</div>', 'data:image/gif'],
];
describe('攻擊向量', () => {
it.each(V)('%s', (n, html) => {
const r = blockRemoteContent(html);
expect({ v: n, leaks: leaks(r.html) }).toEqual({ v: n, leaks: [] });
});
});
describe('必須保留', () => {
it.each(KEEP)('%s', (n, html, needle) => {
const r = blockRemoteContent(html);
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
.toEqual({ v: n, kept: true, blocked: 0 });
});
});

View File

@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
new Blob([raw], { type: 'text/plain' }
))
}

View File

@@ -0,0 +1,184 @@
import DOMPurify from 'dompurify';
// 1x1 transparent GIF, substituted for blocked remote images.
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7';
// Attributes whose value the browser resolves into a request.
const URL_ATTRIBUTES = new Set([
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
'poster', 'background', 'data', 'action', 'formaction',
]);
// Elements that fetch on their own, redirect the frame, or re-base every
// relative URL in the document. None of them belong in a mail body, and
// <base> in particular would turn the relative paths we deliberately keep
// into requests to whatever host it names.
const FORBIDDEN = [
'base', 'meta', 'script', 'link', 'iframe', 'frame', 'frameset',
'object', 'embed', 'noscript', 'template', 'portal',
];
// DOMPurify's default scheme list has no blob:, but email-parser.js rewrites
// cid: attachments into blob: URLs -- without this every inline image would be
// stripped along with the trackers.
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
// CSS constructs that can load a resource. @import is listed because it also
// accepts a bare string -- `@import "https://..."` fetches without any url().
const CSS_FETCHES = /url\(|image-set|image\(|cross-fade|element\(|@import/i;
// A url(...) token or a bare string, either of which can name a resource.
const CSS_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)|(['"])([^'"]*)\3/g;
/**
* Whether a URL can be *proven* to stay off the network.
*
* This is deliberately an allowlist. Asking "does this look remote?" means
* enumerating every way a scheme can be disguised -- backslashes, tabs and
* control characters the URL parser strips, CSS escapes, schemes with no
* slashes -- and losing to the first one not thought of. Asking "can I prove
* this is local?" fails closed instead: anything unrecognised is blocked.
*
* Relative paths qualify only because <base> is removed above, so they can
* resolve to nothing but our own origin.
*/
function provablyLocal(value) {
const url = String(value ?? '').trim();
if (url === '') {
return true;
}
if (/^(?:cid:|blob:|data:image\/)/i.test(url)) {
return true;
}
// Relative: starts with a path/query/fragment marker and is not the
// protocol-relative "//host" form (or its backslash equivalent).
if (/^[/.?#]/.test(url)) {
return !/^[/\\]{2}/.test(url);
}
// No scheme separator and no backslash at all -- a bare relative filename.
return !/[:\\]/.test(url);
}
/** srcset holds several candidates; every one of them has to be local. */
function srcsetIsLocal(value) {
return String(value ?? '')
.split(',')
.map((candidate) => candidate.trim().split(/\s+/)[0])
.filter(Boolean)
.every(provablyLocal);
}
/**
* Replaces every resource reference in a chunk of CSS that cannot be proven
* local. Tokens are substituted in place rather than whole declarations
* dropped, so the surrounding rule structure survives.
*/
function blockCssUrls(cssText, onBlocked) {
if (!CSS_FETCHES.test(cssText)) {
return cssText;
}
return cssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
const isUrlToken = urlValue !== undefined;
const token = isUrlToken ? urlValue : strValue;
if (provablyLocal(token)) {
return match;
}
onBlocked();
return isUrlToken
? `url(${urlQuote}${TRANSPARENT_PIXEL}${urlQuote})`
: `${strQuote}${TRANSPARENT_PIXEL}${strQuote}`;
});
}
let purifier = null;
let blockedCount = 0;
/**
* An isolated DOMPurify instance. The hooks below must not reach the shared
* singleton, which mail-actions.js uses when building replies -- quoting a
* mail should keep its images.
*/
function getPurifier() {
if (purifier) {
return purifier;
}
purifier = DOMPurify(window);
purifier.addHook('uponSanitizeAttribute', (node, data) => {
if (!URL_ATTRIBUTES.has(data.attrName)) {
return;
}
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
return;
}
blockedCount += 1;
data.keepAttr = false;
// The original URL is dropped rather than parked in a data-* attribute:
// "the cleaned body contains no remote URL at all" is an invariant that
// can be asserted directly, and restoring images re-renders from the
// untouched source anyway. <img> keeps a placeholder so layout holds.
if (data.attrName === 'src' && node.tagName === 'IMG') {
node.setAttribute('src', TRANSPARENT_PIXEL);
}
});
purifier.addHook('afterSanitizeElements', (node) => {
if (node.tagName === 'STYLE') {
const cleaned = blockCssUrls(node.textContent || '', () => { blockedCount += 1; });
if (cleaned !== node.textContent) {
node.textContent = cleaned;
}
}
});
purifier.addHook('afterSanitizeAttributes', (node) => {
const style = node.getAttribute && node.getAttribute('style');
if (!style) {
return;
}
const cleaned = blockCssUrls(style, () => { blockedCount += 1; });
if (cleaned !== style) {
node.setAttribute('style', cleaned);
}
});
return purifier;
}
/**
* Strips everything in an email body that would make the browser fetch from a
* third party, so opening the mail cannot be used to confirm it was read.
*
* Sanitising is delegated to DOMPurify rather than hand-rolled: the hard part
* is not enumerating attributes but surviving the parser, and mutation-XSS is
* DOMPurify's specialty. A hand-written pass over a DOMParser tree missed, for
* one example, that <noscript> is parsed as markup where scripting is off and
* as raw text where it is on -- so a `</noscript>` smuggled into an attribute
* value reopens the document at render time and revives an <img> that the
* cleaner never saw.
*
* @param {string} html
* @returns {{ html: string, blocked: number }} blocked counts the references removed
*/
export function blockRemoteContent(html) {
if (!html || typeof html !== 'string') {
return { html: html || '', blocked: 0 };
}
blockedCount = 0;
const sanitised = getPurifier().sanitize(html, {
FORBID_TAGS: FORBIDDEN,
// Mail layout leans on <style> blocks, so they are kept and their
// url() references filtered instead of dropping the tag wholesale.
// FORCE_BODY is what makes a leading <style> survive: without it the
// parser hoists it into <head> and DOMPurify discards it.
ADD_TAGS: ['style'],
FORCE_BODY: true,
ALLOWED_URI_REGEXP,
ALLOW_DATA_ATTR: true,
});
return { html: sanitised, blocked: blockedCount };
}

View File

@@ -12,7 +12,7 @@ const props = defineProps({
const {
mailboxSplitSize, mailListView, mailListPreviewLineClamp, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteImages
} = useGlobalState()
const isMobile = useIsMobile()
@@ -60,6 +60,9 @@ const { t } = useScopedI18n('views.common.Appearance')
<n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('autoLoadRemoteImages')">
<n-switch v-model:value="autoLoadRemoteImages" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>