Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
'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, '&').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 `<!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 };
|
||||
Reference in New Issue
Block a user