Files
MailUI4Agents/client/electron/test/markdown-xss.test.mjs
JianFeeeee b4610f1539 fix(安全)★: index.html 加 CSP —— 渲染层的最后一道防线(实测过不误伤)
**为什么现在没有 XSS**:全库 `dangerouslySetInnerHTML` / `innerHTML` /
`insertAdjacentHTML` 命中 **0**,`react-markdown` 无 `rehype-raw`,
`test/markdown-xss.test.mjs` 钉着。⇒ 这条 CSP **不是**为了堵今天的洞,
而是防「有人加一行 `dangerouslySetInnerHTML` 之后那道防线消失」。
★ 而 `sandbox: true` + `contextIsolation: true` **替不了这一层**:
  那两条只挡 node 与跨上下文,**不挡渲染层注入**。

## ★★ 不是照抄模板 —— 每一档都按仓库真实用法定的

· `script-src` **必须**含 `'unsafe-inline'`:上方那段首帧防闪屏脚本要求
  **同步**执行(早于任何外链,见 index.html 头部注释),无法外链化。
  ⇒ 因此 CSP **挡不住注入型 XSS**,只挡 `javascript:` URL / 外部脚本 / `eval`
  (`script-src` 不含 `'unsafe-eval'`)。★ 这一点已写进 meta 上方的注释 ——
  否则下一个人会以为这是严格 CSP。要去掉就得外链化那段脚本,而那会**引入闪屏**。
· `style-src` 同理 + `img-src`/`font-src` 含 `data:`:
  `backgroundStore.ts:252-262` 用 `root.style.setProperty('--bg-image', url("data:image/…"))`
  ⇒ 背景图就是 data URL。
· `connect-src` **只能是 `'self'` + 宽松一档**:网关地址**用户可填**
  (`AccountList` 的 placeholder 就是「Gateway 地址,如 http://192.168.2.60:8180」),
  可以是**任意 host:port** ⇒ 这里没法写成 allow-list。
  真正的防线在渲染层与主进程(导航拦截),不在这一行。
· `object-src 'none'` / `base-uri 'self'` / `frame-ancestors 'none'` / `form-action 'self'`
  是纯收紧,**零兼容代价**。

## ★ 真的用 chromium 实测过它不误伤(不是推理)

无头加载一份带同款 CSP 的页面,结果写进 DOM:
```
id="R"> pending | inline-ran | cssom-ran | eval-BLOCKED
```
⇒ 内联脚本跑(防闪屏有效)、CSSOM 设 data: 背景有效、`eval` 被拦。
且构建产物 `dist/index.html` 里 CSP 确实在位(`npm run build` 后 grep 得到)。

## 判据(markdown-xss 9 → 19,已接线)

新增 10 格。变异测试 5 个全部抓住:删整条 CSP / 去掉 `object-src 'none'` /
去掉 `frame-ancestors` / 去掉 `img-src` 的 `data:` / 加 `'unsafe-eval'`。
★ 顺带把「防闪屏脚本必须是单引号 `classList.add('dark')`」也钉在这里 ——
  `index.html` 的注释声称 `test/theme.test.mjs` 在逐字符断言它,
  **实测 theme.test.mjs 并不断言引号**(它只切 `.dark {}` 色块)⇒ 那条注释是**过期的**,
  历史上被 prettier 改写过一次的事件其实**没有判据在挡**。现已由本条补上。

★ 取 CSP 值时踩了两个坑(都记在判据注释里):`[^>]*` 会在策略里的
  `'self'>` 处提前结束;按行匹配会因 `<meta>` 跨行而只取到第一行。
  ⇒ 判据不匹配标签,只确认 meta 存在再单独取 `content="…"`。

## 边界 / 未做

· CSP 只覆盖**浏览器/Web 侧**;Electron 的 `file://` 加载下 meta CSP 仍生效,
  但主进程那道防线是 `will-navigate` / `setWindowOpenHandler`
  (`test/main-process-security.test.mjs` 钉着)。
· **没在真机上验过 CSP** —— Electron `loadFile()` 那条路未实测
  (只验了 chromium 下的 `file://`)。下次装机时顺手看一眼首帧有没有闪。
2026-10-03 21:21:55 +08:00

128 lines
6.9 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.

// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
// react-markdown 默认不解析 raw HTML(无 rehype-raw),且用 defaultUrlTransform
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
//
// 运行:node test/markdown-xss.test.mjs
import { renderToStaticMarkup } from 'react-dom/server';
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { check, finish } from './lib/checks.mjs';
import { prose } from './lib/read.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// 只有【真实标签】里的危险内容才算漏。
// 注意不能直接搜 onerror=:raw HTML 被转义成 &lt;img … onerror=&quot; 后,
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
const payloads = [
'<script>alert(1)</script>',
'<img src=x onerror="alert(1)">',
'[click](javascript:alert(1))',
'<a href="javascript:alert(1)">x</a>',
'<iframe src="https://evil.com"></iframe>',
'![x](javascript:alert(1))',
'<div onmouseover="alert(1)">hover</div>',
'[ok](https://example.com)',
'**bold** `code`',
];
// 每条 payload 就是一条判据(换来的是"跑了几条"可核对,而不是只报一句总括)
console.log('Markdown 注入回归:');
for (const p of payloads) {
const html = renderToStaticMarkup(
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
);
const bad = dangerous.test(html);
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
check(`危险内容被清除:${JSON.stringify(p)}`, !bad, `渲染结果里出现了真实可执行的危险标记:${html.slice(0, 120)}`);
}
/*
* ══════════════ ★★ CSP 必须在(渲染层的**最后一道**)══════════════
*
* ## 为什么有这条判据
*
* 上面那批 payload 守的是**渲染器**(raw HTML 转义、`javascript:` 中和)。
* 这一条守的是**入口页**:CSP 没了,上面的防线就只是“库当前恰好这么写”。
* ★ 而 `sandbox: true` + `contextIsolation: true` **替不了这一层** ——
* 那两条只挡 node 与跨上下文,**不挡渲染层注入**
* (本条就是证据:没有 CSP 时,一个 `dangerouslySetInnerHTML` 就能开一口子)。
*
* ## 为什么本条只判“存在 + 那几档纯收紧”,不判“够严格”
*
* CSP 的强度取决于部署形态:网关地址**用户可填**(任意 host:port)
* ⇒ `connect-src` 没法写成 allow-list。而首帧防闪屏那段脚本**必须内联**
* ⇒ `script-src` 必须留 `'unsafe-inline'`。
* ⇒ 真正能收紧的那三档(`object-src 'none'` / `base-uri 'self'` /
* `frame-ancestors 'none'`)**没有任何兼容代价**,本条就钉它们。
* 至于强度,**不是判据能保证的**,写在这里免得下一个人以为它是严格 CSP。
*/
const html = prose(join(dirname(fileURLToPath(import.meta.url)), '..', 'index.html'));
/*
* ★ 取 `content` 的值:**不能**用 `/content=["']([^"']+)["']/` ——
* CSP 本身用 `;` 分档,于是**第一档 `default-src 'self';` 就把正则截断了**
* (第一版就是这么写的,7 格全红而报的策略只有 `default-src `)。
* 正确做法:先切出整个 `<meta …>` 标签,再在**标签内部**按引号取 content,
* 分号对它没有特殊含义。
*/
/*
* ★★ 取值踩了两个坑,都在同一行正则上(第一版两个都踩了):
*
* 坑 1:**不能**用 `/content=["']([^"']+)["']/` 直接取 ——
* CSP 用 `;` 分档,可 `[^"']+` 不会在 `;` 处停,只会被**行尾/标签边界**停,
* 而 `<meta …>` 是**跨行**写的 ⇒ 配合 `[^>]*` 只匹配到第一行就断了
* (报出来的策略只有 `default-src `)。
*
* 坑 2:`[^>]*` 在 CSP **内容里**就断了 —— 策略里有 `'self'>` 这种
* 带 `>` 的片段(`'self'; …` 里的分号后面无所谓,但 `…'none'>` 这种
* 一旦出现就提前结束)。实测本文件的策略含 `'self';`,
* 旧正则匹配到的是 `content="default-src 'self'; script` 就停了。
*
* ⇒ 正确做法:**不匹配标签**,只确认 meta 存在,再单独取 `content="…"`
* (属性值里没有未转义的 `"`,所以按引号取是安全的)。同族第 4、5 次露头。
*/
const hasCspMeta = /<meta\b[^>]*http-equiv\s*=\s*["']Content-Security-Policy["']/i.test(html);
check('index.html 带 CSP meta(渲染层的最后一道防线)', hasCspMeta,
'index.html 没有 Content-Security-Policy —— sandbox/contextIsolation 挡的是 node 与跨上下文,**不挡渲染层注入**');
const csp = (() => {
// 取紧跟在 http-equiv 之后的那个 content 属性(不跨标签,只在文档里找那一对引号)
const at = html.search(/http-equiv\s*=\s*["']Content-Security-Policy["']/i);
if (at < 0) return '';
const tail = html.slice(at);
const m = /content\s*=\s*"(.*?)"\s*(?:\/>|>|\s)/s.exec(tail);
return m ? m[1] : '';
})();
check('CSP 含 script-src', /script-src/.test(csp), `实际策略:${csp.slice(0, 120)}`);
check("CSP **不含** 'unsafe-eval'(没有 eval 需求)", !csp.includes("'unsafe-eval'"),
'script-src 里不该有 unsafe-eval —— Vite/React 产物不需要 eval');
check("CSP 的 script-src 允许 'unsafe-inline'(首帧防闪屏那段必须内联同步执行)",
/script-src[^;]*'unsafe-inline'/.test(csp),
"首帧防闪屏脚本要求同步执行(见 index.html 的注释),拿掉会闪白屏 —— 这条要有,且**必须在注释里写明它挡不住注入**");
/* 这三档是纯收紧:任何形态下都该有,且收不收都不影响功能 */
for (const [name, re] of [
["object-src 'none'", /object-src\s+'none'/],
["base-uri 'self'", /base-uri\s+'self'/],
["frame-ancestors 'none'", /frame-ancestors\s+'none'/],
["img-src 含 data:(背景图就是 data URL)", /img-src[^;]*data:/],
["style-src 含 'unsafe-inline'(CSSOM setProperty 设 --bg-image)", /style-src[^;]*'unsafe-inline'/]
]) {
check(`CSP ${name}`, re.test(csp), `缺这一档:${csp.slice(0, 140)}`);
}
/*
* ★ 反向:`index.html` 里那段防闪屏脚本的**引号**不能被顺手统一。
* `test/theme.test.mjs` 逐字符断言 `classList.add('dark');` 是单引号;
* 本条判 CSP 时正好把那条纪律再钉一次(历史上被 prettier 改过一次)。
*/
check("首帧脚本仍是单引号 classList.add('dark')(别格式化)",
html.includes("classList.add('dark')"),
"index.html 的防闪屏脚本里必须是 classList.add('dark')(单引号)—— test/theme.test.mjs 逐字符断言它");
finish('Markdown 注入');