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
This commit is contained in:
Dream Hunter
2026-07-29 15:36:11 +08:00
committed by GitHub
parent e499211197
commit b3666c0900
4 changed files with 80 additions and 2 deletions

View File

@@ -16,6 +16,7 @@
### Bug Fixes
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>``<area>` 的外部导航链接,并阻断通过 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| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中

View File

@@ -16,6 +16,7 @@
### Bug Fixes
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` 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

View File

@@ -38,6 +38,9 @@ const V = [
['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>`],
['attr CSS 函式名稱跳脫', `<div style="background:u\\72l(${T})">x</div>`],
['style CSS 函式名稱跳脫', `<style>.a{background:u\\72l(${T})}</style>`],
['style CSS at-rule 跳脫', `<style>@im\\70ort "${T}";</style>`],
['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>`],
@@ -63,6 +66,8 @@ const KEEP = [
['排版 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'],
['外部 a 連結', `<a href="${T}">open</a>`, T],
['外部 area 連結', `<map name="m"><area href="${T}" coords="0,0,1,1"></map>`, 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(
'<div style="font-family:\\41 rial;background:url(data:image/gif;base64,AAAA)">x</div>'
);
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(
'<div style="color:red;background:u\\72l(https://a.example/a.png),u\\72l(https://b.example/b.png)">x</div>'
);
expect(r.blocked).toBe(2);
expect(r.html).toContain('color:red');
expect(r.html).not.toContain('https://');
});
});
describe('危險導航協議', () => {
it.each([
['a javascript', '<a href="javascript:alert(1)">x</a>', 'a'],
['a data:text/html', '<a href="data:text/html,<script>alert(1)</script>">x</a>', 'a'],
['area javascript', '<map><area href="javascript:alert(1)"></map>', 'area'],
['area data:text/html', '<map><area href="data:text/html,<script>alert(1)</script>"></map>', '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);
});
});

View File

@@ -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;