**为什么现在没有 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://`)。下次装机时顺手看一眼首帧有没有闪。
107 lines
5.8 KiB
HTML
107 lines
5.8 KiB
HTML
<!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>
|