'use strict'; /** * HTML 邮件净化。 * * 为什么需要它:邮件正文是**别人**写的代码,直接塞进页面等于把我们的界面交给发件人。 * 这里做第一层(去脚本/事件/危险协议 + 默认不加载远程图片), * 第二层在界面上:正文渲染在 `` 里(**不给 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(/]*>/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 `🖼 已拦截远程图片${alt ? `(${escapeAttr(alt)})` : ''}`; }); 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, '&').replace(//g, '>').replace(/"/g, '"'); } /** * 把净化的 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 ` ${bodyHtml}`; } module.exports = { sanitizeHtml, buildDocument };