264 lines
13 KiB
JavaScript
264 lines
13 KiB
JavaScript
'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;
|
||
}
|
||
})();
|