From b3666c0900735ea0aa3b44b3d02f2a879ddf3323 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Wed, 29 Jul 2026 15:36:11 +0800 Subject: [PATCH] fix: harden remote content policy edge cases (#1095) * fix: harden remote content filtering edge cases * fix: preserve safe escaped CSS * test: cover unsafe navigation protocols --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + .../__tests__/remote-content-policy.test.js | 40 +++++++++++++++++++ frontend/src/utils/remote-content-policy.js | 40 ++++++++++++++++++- 4 files changed, 80 insertions(+), 2 deletions(-) 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', '
hi
', '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;