mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-08 08:57:22 +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
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>` 与 `<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
|
||||||
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
||||||
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
||||||
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
### Bug Fixes
|
### 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: |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: |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
|
- 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>`],
|
['style 誘餌 url(', `<style>.a{content:"url(";background:url(${T})}</style>`],
|
||||||
['attr image-set', `<div style="background:image-set('${T}' 1x)">x</div>`],
|
['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: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">`],
|
['URL 反斜線', `<img src="https:\\\\tracker.example\\p.png">`],
|
||||||
['scheme 無斜線', `<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>`],
|
['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'],
|
['排版 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'],
|
['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'],
|
['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('攻擊向量', () => {
|
describe('攻擊向量', () => {
|
||||||
@@ -78,4 +83,39 @@ describe('必須保留', () => {
|
|||||||
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
|
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
|
||||||
.toEqual({ v: n, kept: true, blocked: 0 });
|
.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',
|
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
|
||||||
'poster', 'background', 'data', 'action', 'formaction',
|
'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
|
// 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
|
// 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;
|
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.
|
// 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_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.
|
* 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.
|
* dropped, so the surrounding rule structure survives.
|
||||||
*/
|
*/
|
||||||
function blockCssUrls(cssText, onBlocked) {
|
function blockCssUrls(cssText, onBlocked) {
|
||||||
if (!CSS_FETCHES.test(cssText)) {
|
const normalizedCssText = normalizeCssFetchIdentifiers(cssText);
|
||||||
|
if (!CSS_FETCHES.test(normalizedCssText)) {
|
||||||
return cssText;
|
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 isUrlToken = urlValue !== undefined;
|
||||||
const token = isUrlToken ? urlValue : strValue;
|
const token = isUrlToken ? urlValue : strValue;
|
||||||
if (provablyLocal(token)) {
|
if (provablyLocal(token)) {
|
||||||
@@ -108,6 +141,9 @@ function getPurifier() {
|
|||||||
if (!URL_ATTRIBUTES.has(data.attrName)) {
|
if (!URL_ATTRIBUTES.has(data.attrName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
|
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
|
||||||
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
|
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user