mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-06 14:03:58 +08:00
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:
@@ -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| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,<img src=${T}>"></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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user