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://`)。下次装机时顺手看一眼首帧有没有闪。
This commit is contained in:
2026-10-03 21:21:55 +08:00
parent d284f1f0af
commit b4610f1539
3 changed files with 126 additions and 1 deletions

View File

@ -26,6 +26,45 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="AgentMail" />
<!--
CSP(2026-10-03 新增)。
目的:**不是**“现在有 XSS”——渲染层已经很干净
(全库 `dangerouslySetInnerHTML` / `innerHTML` / `insertAdjacentHTML` 命中 **0**,
`react-markdown` 无 `rehype-raw`,`test/markdown-xss.test.mjs` 钉着)。
它防的是**“有人加一行 `dangerouslySetInnerHTML` 之后那道防线消失”**。
★ 而 `sandbox: true` + `contextIsolation: true` **替不了这一层**:
那两条只挡 node 与跨上下文,**不挡渲染层里的注入**。
每一档为什么是这个值(都是按仓库真实用法定的,不是照抄模板):
· script-src **必须**含 `'unsafe-inline'` —— 上方那段首帧防闪屏脚本
要求**同步**执行(早于任何外链),没法外链化。
⇒ 这意味着 CSP **挡不住注入型 XSS**,只挡 `javascript:` URL、
外部脚本、`eval`(`script-src` 不含 `'unsafe-eval'`)。
★ 要去掉它就得先把那段脚本外链化,而那会**引入闪屏**(本文件头部
注释里记的就是那个问题)—— 两害相权,保留并在此写明。
· style-src 同理:`backgroundStore.ts:252-262` 用
`root.style.setProperty('--bg-image', url("data:image/…"))`
⇒ 需要 `style-src 'self' 'unsafe-inline'`,且 `img-src`/`font-src`
都要含 `data:`(背景图就是 data URL)。
· connect-src **只能是 `'self'` + 极宽松的一档** ——
网关地址**用户可填**(`AccountList` 的 placeholder 就是
“Gateway 地址,如 http://192.168.2.60:8180”),可以是**任意 host:port**
⇒ 这里没法写成 allow-list。dev 走 vite proxy(同源),
prod(Electron)走 `file://` + 注入的 API base。
⇒ 真正的防线在渲染层与主进程(导航拦截),不在这一行。
· `object-src 'none'` / `base-uri 'self'` / `frame-ancestors 'none'`
这三档是纯收紧,**没有任何兼容代价**。
⚠️ 这条 CSP 只覆盖**浏览器/Web 侧**。Electron 的 `file://` 加载下
`meta` CSP 仍然生效,但主进程那道防线是 `main.cjs` 的
`will-navigate` / `setWindowOpenHandler`(`test/main-process-security.test.mjs` 钉着)。
-->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
/>
<title>AgentMail</title>
<script>
/*

View File

@ -10,6 +10,9 @@ 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; 后,
@ -38,4 +41,87 @@ for (const p of payloads) {
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 注入');

View File

@ -60,7 +60,7 @@ for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释
}
const SUITE = [
['test/markdown-xss.test.mjs', [], 9],
['test/markdown-xss.test.mjs', [], 19],
['test/narrow-layout.test.mjs', [], 88],
['test/nav-merge.test.mjs', [], 9],
/*