Files

127 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* HTML 邮件净化。
*
* 为什么需要它:邮件正文是**别人**写的代码,直接塞进页面等于把我们的界面交给发件人。
* 这里做第一层(去脚本/事件/危险协议 + 默认不加载远程图片),
* 第二层在界面上:正文渲染在 `<iframe sandbox>` 里(**不给 allow-scripts**),
* 就算这里漏掉什么,脚本也执行不起来。
*
* 说明(不粉饰):这是基于正则的净化,不是完整的 HTML 解析器。
* 之所以接受这个折中:① 客户端是零依赖的;② 真正的安全边界是沙箱 iframe,
* 这一层只负责把「不该出现的东西」清掉,让渲染更干净、也不至于一打开就连外网。
*/
/** 整段删掉的标签(含内容)。 */
const DROP_WITH_CONTENT = ['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'noscript', 'template', 'svg', 'math'];
/** 自闭合/空标签,直接删标签本身。 */
const DROP_TAGS = ['meta', 'link', 'base', 'input', 'button', 'textarea', 'select', 'option'];
const URL_ATTRIBUTES = ['href', 'src', 'action', 'formaction', 'background', 'poster', 'xlink:href'];
function dropBlocks(html, tag) {
const paired = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, 'gi');
let out = html.replace(paired, '');
// 没有闭合的(被截断的)也要清掉
out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*$`, 'gi'), '');
out = out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '');
return out;
}
/**
* @param {string} html 原始 HTML 正文
* @param {{allowRemoteImages?: boolean}} options
* @returns {{html: string, blockedImages: string[], removed: string[]}}
*/
function sanitizeHtml(html, options = {}) {
const allowRemoteImages = !!options.allowRemoteImages;
const removed = [];
const blockedImages = [];
let out = String(html || '');
for (const tag of DROP_WITH_CONTENT) {
const before = out;
out = dropBlocks(out, tag);
if (out !== before) removed.push(tag);
}
for (const tag of DROP_TAGS) {
const before = out;
out = out.replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, 'gi'), '');
if (out !== before) removed.push(tag);
}
// 事件处理器:on* 属性(onclick、onerror…)
out = out.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m) => {
if (!removed.includes('事件处理器')) removed.push('事件处理器');
return '';
});
// 危险协议
out = out.replace(new RegExp(`(${URL_ATTRIBUTES.join('|')})\\s*=\\s*("|')?\\s*(javascript|vbscript|data:text\\/html)[^"'>\\s]*\\2?`, 'gi'), (m, attr) => {
if (!removed.includes('危险链接')) removed.push('危险链接');
return `${attr}="#"`;
});
// 远程图片:默认拦掉(否则一打开邮件就把你的 IP、打开时间、邮件 id 告诉发件人)
if (!allowRemoteImages) {
out = out.replace(/<img\b[^>]*>/gi, (tag) => {
const srcMatch = tag.match(/\ssrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
const src = srcMatch ? (srcMatch[2] || srcMatch[3] || srcMatch[4] || '') : '';
if (!/^(https?:)?\/\//i.test(src)) return tag; // cid:/data: 的图片保留(一般是内嵌附件)
blockedImages.push(src);
const alt = (tag.match(/\salt\s*=\s*("([^"]*)"|'([^']*)')/i) || [])[2] || '';
return `<span style="display:inline-block;border:1px dashed #bbb;padding:4px 8px;border-radius:6px;`
+ `font:12px/1.4 sans-serif;color:#888">🖼 已拦截远程图片${alt ? `(${escapeAttr(alt)})` : ''}</span>`;
});
if (blockedImages.length > 0 && !removed.includes('远程图片')) removed.push('远程图片');
}
// 只留安全的 style 属性(去掉 expression/behavior/url(javascript:))
out = out.replace(/\sstyle\s*=\s*("([^"]*)"|'([^']*)')/gi, (m, _all, dq, sq) => {
const value = dq != null ? dq : sq || '';
if (/expression\s*\(|behavior\s*:|javascript:/i.test(value)) {
if (!removed.includes('危险样式')) removed.push('危险样式');
return '';
}
return m;
});
return { html: out, blockedImages, removed: [...new Set(removed)] };
}
function escapeAttr(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 把净化的 HTML 包成一个自带样式的完整文档,用于 iframe srcDoc。
* 关键点:
* - CSP 里禁掉脚本与远程加载(第一层保险)
* - 图片被拦时给出提示条 + 「显示图片」按钮(按钮由界面在 iframe 外提供,这里只放占位)
*/
function buildDocument(bodyHtml, options = {}) {
const theme = options.theme === 'dark' ? 'dark' : 'light';
const bg = theme === 'dark' ? '#0a0a0a' : '#ffffff';
const fg = theme === 'dark' ? '#e5e5e5' : '#111111';
const link = theme === 'dark' ? '#7cc4ff' : '#0b62d0';
return `<!doctype html>
<html><head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: cid:; style-src 'unsafe-inline'; font-src data:; form-action 'none'; base-uri 'none'">
<style>
:root { color-scheme: ${theme}; }
html, body { margin: 0; padding: 0; background: ${bg}; color: ${fg}; }
body { font: 14px/1.7 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; padding: 16px 20px; word-break: break-word; }
img { max-width: 100%; height: auto; }
table { max-width: 100%; }
blockquote { margin: 0 0 0 12px; padding-left: 12px; border-left: 2px solid #8884; color: #8888; }
a { color: ${link}; }
pre { white-space: pre-wrap; }
</style>
</head><body>${bodyHtml}</body></html>`;
}
module.exports = { sanitizeHtml, buildDocument };