**为什么现在没有 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://`)。下次装机时顺手看一眼首帧有没有闪。
128 lines
6.9 KiB
JavaScript
128 lines
6.9 KiB
JavaScript
// 回归测试:确认邮件正文的 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 = [
|
||
'<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>',
|
||
')',
|
||
'<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 注入');
|