Files
MailUI4Agents/client/electron/index.html
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

107 lines
5.8 KiB
HTML
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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<!--
图标放在 public/assets/ 而不是 public/ 根下:Gateway 只把 /assets/* 和 /
挂给了静态处理器(见 server/cmd/server/main.go 的静态段),放在根下会 404。
Vite 原样拷贝 public/ 到 dist/,所以开发态与内嵌后 URL 都是 /assets/…。
品牌源文件与其生成脚本:client/electron/src/icons/。
-->
<link rel="icon" type="image/svg+xml" href="/assets/agentmail.svg" />
<link rel="alternate icon" type="image/x-icon" href="/assets/favicon.ico" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
<meta name="theme-color" content="#2563eb" />
<!--
沉浸式(用户 2026-09-14:「不支持沉浸式网页」)。
原先没有 manifest,也没有 iOS 全屏 meta ⇒ 在手机上「添加到主屏幕」后打开,
看到的仍然是**带地址栏的网页**。加上 standalone + black-translucent
(状态栏内容叠在页面上)之后才是真正的全屏;安全区由 viewport-fit=cover
与 .safe-frame / .narrow-nav 的 env(safe-area-inset-*) 负责,不会被刘海挡住。
-->
<link rel="manifest" href="./manifest.webmanifest" />
<meta name="mobile-web-app-capable" content="yes" />
<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>
/*
* 首帧防闪屏。
*
* JS bundle 有几百 KB,从 HTML 解析完到 React 挂载之间有一段空窗 ——
* 那段时间里页面是 body 的默认底色。深色偏好的用户会被闪一下白屏,
* 而且是每次刷新都闪。
*
* 这段脚本必须**内联且同步**:外链或 defer 都会晚于首次绘制。
* 逻辑与 themeStore 的 readStored/resolveTheme 一致,
* 改那边的键名或取值时这里要同步。
*
* 引号别「顺手统一」成双引号:test/theme.test.mjs 逐字符断言这一段里是
* classList.add('dark')(单引号)。曾经被 prettier 改写过一次,直接让测试变红。
*/
(function () {
try {
var p = localStorage.getItem('agentmail.theme') || 'system';
var dark =
p === 'dark' ||
(p === 'system' &&
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) {
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
}
} catch (e) {
/* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */
}
})();
</script>
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>