// 回归测试:确认邮件正文的 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 被转义成 <img … onerror=" 后,
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
const payloads = [
'',
'
',
'[click](javascript:alert(1))',
'x',
'',
')',
'
hover
',
'[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 `)。
* 正确做法:先切出整个 `` 标签,再在**标签内部**按引号取 content,
* 分号对它没有特殊含义。
*/
/*
* ★★ 取值踩了两个坑,都在同一行正则上(第一版两个都踩了):
*
* 坑 1:**不能**用 `/content=["']([^"']+)["']/` 直接取 ——
* CSP 用 `;` 分档,可 `[^"']+` 不会在 `;` 处停,只会被**行尾/标签边界**停,
* 而 `` 是**跨行**写的 ⇒ 配合 `[^>]*` 只匹配到第一行就断了
* (报出来的策略只有 `default-src `)。
*
* 坑 2:`[^>]*` 在 CSP **内容里**就断了 —— 策略里有 `'self'>` 这种
* 带 `>` 的片段(`'self'; …` 里的分号后面无所谓,但 `…'none'>` 这种
* 一旦出现就提前结束)。实测本文件的策略含 `'self';`,
* 旧正则匹配到的是 `content="default-src 'self'; script` 就停了。
*
* ⇒ 正确做法:**不匹配标签**,只确认 meta 存在,再单独取 `content="…"`
* (属性值里没有未转义的 `"`,所以按引号取是安全的)。同族第 4、5 次露头。
*/
const hasCspMeta = /]*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 注入');