mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-07 22:43:39 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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": "自动刷新间隔(秒)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
81
frontend/src/utils/__tests__/remote-content-policy.test.js
Normal file
81
frontend/src/utils/__tests__/remote-content-policy.test.js
Normal 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="<img src=${T}>"></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,<img src=${T}>"></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://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 });
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
|
||||
new Blob([raw], { type: 'text/plain' }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
184
frontend/src/utils/remote-content-policy.js
Normal file
184
frontend/src/utils/remote-content-policy.js
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user