diff --git a/CHANGELOG.md b/CHANGELOG.md
index bba56036..30f1bdf8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@
### Bug Fixes
+- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `` 与 `` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md
index 09cd525f..54315656 100644
--- a/CHANGELOG_EN.md
+++ b/CHANGELOG_EN.md
@@ -16,6 +16,7 @@
### Bug Fixes
+- fix: |Frontend| Preserve external navigation links on `` and `` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
diff --git a/frontend/src/utils/__tests__/remote-content-policy.test.js b/frontend/src/utils/__tests__/remote-content-policy.test.js
index da590f7b..d0171e53 100644
--- a/frontend/src/utils/__tests__/remote-content-policy.test.js
+++ b/frontend/src/utils/__tests__/remote-content-policy.test.js
@@ -38,6 +38,9 @@ const V = [
['style 誘餌 url(', ``],
['attr image-set', `x
`],
['attr CSS 跳脫', `x
`],
+ ['attr CSS 函式名稱跳脫', `x
`],
+ ['style CSS 函式名稱跳脫', ``],
+ ['style CSS at-rule 跳脫', ``],
['URL 反斜線', `
`],
['scheme 無斜線', `
`],
['data:text/html iframe', ``],
@@ -63,6 +66,8 @@ const KEEP = [
['排版 CSS', '
', 'padding:8px'],
['style 區塊排版', 'x
', 'font-size:14px'],
['data: 於 CSS', 'x
', 'data:image/gif'],
+ ['外部 a 連結', `open`, T],
+ ['外部 area 連結', ``, T],
];
describe('攻擊向量', () => {
@@ -78,4 +83,39 @@ describe('必須保留', () => {
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
.toEqual({ v: n, kept: true, blocked: 0 });
});
+
+ it('保留安全 CSS 跳脫與本地資源', () => {
+ const r = blockRemoteContent(
+ 'x
'
+ );
+ expect(r.blocked).toBe(0);
+ expect(r.html).toContain('font-family:\\41 rial');
+ expect(r.html).toContain('data:image/gif');
+ });
+});
+
+describe('阻斷計數', () => {
+ it('逐一計算 CSS 跳脫函式中的遠端資源', () => {
+ const r = blockRemoteContent(
+ 'x
'
+ );
+ expect(r.blocked).toBe(2);
+ expect(r.html).toContain('color:red');
+ expect(r.html).not.toContain('https://');
+ });
+});
+
+describe('危險導航協議', () => {
+ it.each([
+ ['a javascript', 'x', 'a'],
+ ['a data:text/html', 'x', 'a'],
+ ['area javascript', '', 'area'],
+ ['area data:text/html', '', 'area'],
+ ])('%s', (_name, html, selector) => {
+ const host = document.createElement('div');
+ host.innerHTML = blockRemoteContent(html).html;
+ const node = host.querySelector(selector);
+ expect(node).not.toBeNull();
+ expect(node.hasAttribute('href')).toBe(false);
+ });
});
diff --git a/frontend/src/utils/remote-content-policy.js b/frontend/src/utils/remote-content-policy.js
index c72dd866..28ae28fb 100644
--- a/frontend/src/utils/remote-content-policy.js
+++ b/frontend/src/utils/remote-content-policy.js
@@ -8,6 +8,7 @@ const URL_ATTRIBUTES = new Set([
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
'poster', 'background', 'data', 'action', 'formaction',
]);
+const NAVIGATION_HREF_ELEMENTS = new Set(['A', 'AREA']);
// 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
@@ -29,6 +30,37 @@ const ALLOWED_URI_REGEXP =
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;
+const CSS_ESCAPE = /\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f0-9a-f])/gi;
+const CSS_ESCAPED_IDENTIFIER =
+ /@?(?:[-_a-z0-9]|\\(?:[0-9a-f]{1,6}[ \t\r\n\f]?|[^\r\n\f0-9a-f]))+/gi;
+const CSS_FETCH_IDENTIFIERS = new Set([
+ 'url', 'image-set', '-webkit-image-set', 'image', 'cross-fade',
+ '-webkit-cross-fade', 'element', '-moz-element', '@import',
+]);
+
+function decodeCssEscapes(value) {
+ return value.replace(CSS_ESCAPE, (_match, hex, escaped) => {
+ if (!hex) {
+ return escaped;
+ }
+ const codePoint = Number.parseInt(hex, 16);
+ if (codePoint === 0 || codePoint > 0x10FFFF ||
+ (codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
+ return '\uFFFD';
+ }
+ return String.fromCodePoint(codePoint);
+ });
+}
+
+function normalizeCssFetchIdentifiers(value) {
+ return value.replace(CSS_ESCAPED_IDENTIFIER, (identifier) => {
+ if (!identifier.includes('\\')) {
+ return identifier;
+ }
+ const decoded = decodeCssEscapes(identifier);
+ return CSS_FETCH_IDENTIFIERS.has(decoded.toLowerCase()) ? decoded : identifier;
+ });
+}
/**
* Whether a URL can be *proven* to stay off the network.
@@ -74,10 +106,11 @@ function srcsetIsLocal(value) {
* dropped, so the surrounding rule structure survives.
*/
function blockCssUrls(cssText, onBlocked) {
- if (!CSS_FETCHES.test(cssText)) {
+ const normalizedCssText = normalizeCssFetchIdentifiers(cssText);
+ if (!CSS_FETCHES.test(normalizedCssText)) {
return cssText;
}
- return cssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
+ return normalizedCssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
const isUrlToken = urlValue !== undefined;
const token = isUrlToken ? urlValue : strValue;
if (provablyLocal(token)) {
@@ -108,6 +141,9 @@ function getPurifier() {
if (!URL_ATTRIBUTES.has(data.attrName)) {
return;
}
+ if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) {
+ return;
+ }
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
return;