// 回归测试:确认邮件正文的 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', '', '![x](javascript:alert(1))', '
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 注入');