Files
wpywmail-client/tools/ui-check-v4.js
T

264 lines
13 KiB
JavaScript
Raw 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 邮件渲染验收(v4)。
*
* 验的是四件事:
* 1. 富文本正文真的渲染出来了(iframe 里有内容,不是空白)
* 2. **邮件里的 <script> 绝对不能执行**(这是最关键的一条:正文是别人写的代码)
* 3. 远程图片默认被拦(否则一打开就把你的 IP/时间告诉发件人),可以手动放行
* 4. 富文本 / 纯文本可以切换
*
* 做法:用客户端自己的发信接口给自己发一封**带恶意脚本的 HTML 邮件**,然后打开它。
* 用法:node tools/ui-check-v4.js [url] [--creds <account.json>] [--keep]
*/
const { spawn } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const URL_ = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : 'http://127.0.0.1:8788/';
const credsArg = process.argv.indexOf('--creds');
const CREDS = credsArg > 0 ? process.argv[credsArg + 1] : path.join(__dirname, '..', 'data', 'account.json');
const KEEP = process.argv.includes('--keep');
const PORT = 9228;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const report = [];
const results = [];
function say(s = '') { report.push(s); }
function step(name, ok, detail = '') {
results.push({ name, ok, detail });
report.push(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ' —— ' + detail : ''}`);
process.stdout.write(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' —— ' + detail : ''}\n`);
}
async function api(base, method, p, body) {
const res = await fetch(base + p, {
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
class Cdp {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.errors = []; }
static async connect(url) {
const ws = new WebSocket(url);
await new Promise((res, rej) => {
ws.addEventListener('open', res, { once: true });
ws.addEventListener('error', () => rej(new Error('CDP 连接失败')), { once: true });
});
const c = new Cdp(ws);
ws.addEventListener('message', (ev) => {
let m; try { m = JSON.parse(ev.data); } catch { return; }
if (m.id && c.pending.has(m.id)) {
const { resolve, reject } = c.pending.get(m.id);
c.pending.delete(m.id);
if (m.error) reject(new Error(m.error.message)); else resolve(m.result);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
c.errors.push('EXCEPTION: ' + (d.exception && d.exception.description ? d.exception.description.split('\n')[0] : d.text));
} else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
c.errors.push('console.error: ' + m.params.args.map((a) => a.value || a.description || '').join(' '));
}
});
return c;
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(method + ' 超时')); } }, 20000);
});
}
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || 'evaluate 异常');
return r.result ? r.result.value : undefined;
}
}
(async () => {
const base = URL_.replace(/\/$/, '');
const stamp = Date.now().toString().slice(-6);
const subject = `HTML 渲染验收 ${stamp}`;
let uid = null;
let cdp = null;
let child = null;
try {
const creds = JSON.parse(fs.readFileSync(CREDS, 'utf8'));
const login = await api(base, 'POST', '/api/login', {
host: creds.host, user: creds.user, password: creds.password, displayName: creds.displayName || '',
});
step('客户端登录', !!login.ok, `${(login.folders || []).length} 个文件夹`);
// ── 1) 发一封带「恶意脚本 + 远程图片」的 HTML 邮件给自己
const evilHtml = [
'<div style="font-family:sans-serif">',
`<p>富文本正文 <b>加粗</b> <span style="color:#c00">红色</span> ${stamp}</p>`,
'<script>window.__pwned = true; document.title = "PWNED";</script>',
'<img src="https://example.com/tracker.gif?x=1" onerror="window.__pwned=true">',
'<a href="javascript:window.__pwned=true">危险链接</a>',
'<iframe src="https://example.com/x"></iframe>',
'</div>',
].join('');
await api(base, 'POST', '/api/send', {
to: creds.user, subject, text: `这是纯文本兜底 ${stamp}`, html: evilHtml,
});
step('已发出带脚本的 HTML 测试邮件', true, subject);
// ── 2) 等服务端投递到自己的收件箱,并检查净化结果
let message = null;
for (let i = 0; i < 30 && !message; i++) {
await sleep(1000);
const list = await api(base, 'GET', `/api/messages?folder=INBOX&limit=20`);
message = (list.messages || []).find((m) => m.subject === subject) || null;
}
step('测试邮件已投递到收件箱', !!message, message ? `uid=${message.uid}` : '(未收到)');
if (!message) throw new Error('没收到测试邮件,后续无法继续');
uid = message.uid;
const detail = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX`);
step('服务端净化:<script> 被清掉', !/<script/i.test(detail.html || ''), `html 长度 ${(detail.html || '').length}`);
step('服务端净化:记录清理了哪些东西', Array.isArray(detail.sanitized) && detail.sanitized.length > 0,
(detail.sanitized || []).join('、'));
step('服务端净化:iframe 被清掉', !/<iframe/i.test(detail.html || ''), '');
step('服务端净化:javascript: 链接被改写', !/href\s*=\s*["']?javascript:/i.test(detail.html || ''), '');
step('服务端净化:事件处理器被清掉', !/\sonerror\s*=/i.test(detail.html || ''), '');
step('远程图片默认被拦', (detail.blockedImages || []).length > 0, `${(detail.blockedImages || []).length} 张`);
step('渲染文档带 CSP 且禁脚本', /Content-Security-Policy/.test(detail.htmlDocument || '')
&& /default-src 'none'/.test(detail.htmlDocument || ''), '');
step('保留了正常内容(加粗文字还在)', /富文本正文/.test(detail.html || '') && /<b>加粗<\/b>/.test(detail.html || ''), '');
const withImages = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX&images=1`);
step('放行图片后不再拦截(img 保留)', /<img/i.test(withImages.html || ''), `${(withImages.blockedImages || []).length} 张被拦`);
// ── 3) 打开界面,点开这封邮件,检查真的渲染了
const edge = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p));
if (!edge) throw new Error('找不到 msedge.exe');
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'wpyw-ui4-'));
child = spawn(edge, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--window-size=1440,900', URL_],
{ stdio: 'ignore' });
let target = null;
for (let i = 0; i < 50 && !target; i++) {
await sleep(400);
try {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json();
target = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
} catch { /* 等 */ }
}
if (!target) throw new Error('等不到 Edge 调试目标');
cdp = await Cdp.connect(target.webSocketDebuggerUrl);
await cdp.send('Runtime.enable');
await cdp.send('Page.enable');
await cdp.send('Page.navigate', { url: URL_ });
let rows = 0;
for (let i = 0; i < 40; i++) {
await sleep(500);
rows = await cdp.eval('document.querySelectorAll("[data-uid]").length');
if (rows > 0) break;
}
step('界面加载出邮件列表', rows > 0, `${rows} 行`);
const clicked = await cdp.eval(`(() => {
const row = [...document.querySelectorAll('[data-uid]')].find(el => el.textContent.includes(${JSON.stringify(stamp)}));
if (!row) return false;
row.click();
return true;
})()`);
step('点开了这封 HTML 邮件', clicked, '');
let probe = null;
for (let i = 0; i < 30; i++) {
await sleep(400);
probe = await cdp.eval(`(() => {
const f = document.querySelector('#html-body');
const article = document.querySelector('article.mail-body');
return {
iframe: !!f,
height: f ? f.getBoundingClientRect().height : 0,
srcdocLen: f && f.getAttribute('srcdoc') ? f.getAttribute('srcdoc').length : 0,
srcdocHasScript: f ? /<script/i.test(f.getAttribute('srcdoc') || '') : null,
srcdocHasCsp: f ? /Content-Security-Policy/.test(f.getAttribute('srcdoc') || '') : null,
sandbox: f ? f.getAttribute('sandbox') : null,
articleVisible: !!article,
toggle: !!document.querySelector('#body-view-toggle'),
notice: document.body.innerText.includes('已拦截') || document.body.innerText.includes('已清理'),
pwned: !!window.__pwned,
pwnedInFrames: (() => { try { return window.frames.length > 0 && !!window.frames[0].__pwned; } catch (e) { return 'cross-origin'; } })(),
};
})()`);
if (probe && probe.iframe && probe.srcdocLen > 0) break;
}
step('HTML 正文渲染在 iframe 里', !!probe && probe.iframe && probe.srcdocLen > 100,
probe ? `srcdoc ${probe.srcdocLen} 字节,高度 ${Math.round(probe.height)}px` : '没找到 #html-body');
step('iframe 带 sandbox 且不给 allow-scripts',
!!probe && typeof probe.sandbox === 'string' && !/allow-scripts/.test(probe.sandbox),
probe ? `sandbox="${probe.sandbox}"` : '');
step('iframe 文档里没有 <script>', !!probe && probe.srcdocHasScript === false, '');
step('iframe 文档带 CSP', !!probe && probe.srcdocHasCsp === true, '');
step('★ 邮件里的脚本没有执行(window.__pwned 未设置)', !!probe && probe.pwned === false,
probe ? String(probe.pwnedInFrames) : '');
step('界面提示了拦截/清理', !!probe && probe.notice, '');
step('有富文本/纯文本切换', !!probe && probe.toggle, '');
// 切纯文本
await cdp.eval(`(() => { const b = document.querySelector('#view-text'); if (b) b.click(); })()`);
await sleep(500);
const textView = await cdp.eval(`(() => ({
iframe: !!document.querySelector('#html-body'),
article: !!document.querySelector('article.mail-body'),
hasText: document.body.innerText.includes(${JSON.stringify(stamp)}),
}))()`);
step('切到纯文本后不再用 iframe、改用 <article>',
!textView.iframe && textView.article && textView.hasText, JSON.stringify(textView));
// 切回富文本
await cdp.eval(`(() => { const b = document.querySelector('#view-html'); if (b) b.click(); })()`);
await sleep(700);
const backToHtml = await cdp.eval('!!document.querySelector("#html-body")');
step('切回富文本正常', backToHtml, '');
const unexpected = cdp.errors.filter((e) => !/403 \(Forbidden\)/.test(e));
step('零 JS 异常 / 零 console.error', unexpected.length === 0, unexpected.slice(0, 3).join(' | '));
} catch (err) {
step('验收过程异常', false, `${err.name}: ${err.message}`);
} finally {
try { if (cdp) cdp.ws.close(); } catch { }
try { if (child) child.kill(); } catch { }
// 清理测试邮件(客户端的删除接口是 POST /api/delete,不是 REST 风格的 DELETE)
if (uid && !KEEP) {
try {
await api(base, 'POST', '/api/delete', { uid, folder: 'INBOX', permanent: true });
step('清理测试邮件(永久删除)', true, `uid=${uid}`);
} catch (err) {
step('清理测试邮件', false, err.message);
}
}
const pass = results.filter((r) => r.ok).length;
const fail = results.length - pass;
say('');
say(`结果:${pass} 项通过,${fail} 项失败`);
for (const r of results.filter((x) => !x.ok)) say(` [失败] ${r.name} —— ${r.detail}`);
try {
const out = path.join(__dirname, '..', '..', 'artifacts', 'client-ui-check-v4.txt');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, report.join('\n'), 'utf8');
} catch { }
process.exitCode = fail === 0 ? 0 : 1;
}
})();