1711 字
9 分钟
给自己的平台做全站体检:7 个 agent 并行审计,CSP 上线后看板娘没了

平台跑到 19 个服务、13 个对外站点之后,安全和一致性问题开始藏在缝里:有的页面未登录返回 303 跳 SSO,有的裸吐 401 JSON;登录页可以被任意网站 iframe;favicon 有的缺失有的 1MB。这次做了一轮系统性审计加修复,记录方法和几个值得留档的坑。

审计方法:7 个并行只读 agent#

一个人肉过 13 个站点不现实,拆成 7 个并行的只读审计任务:

  • live HTTP 探测:对每个站点测安全头、404 行为、trailing-slash 边界、CORS 白名单(用 Origin: evil.example 打 OPTIONS 验证不反射)、http→https、健康端点
  • 前端源码:三个 Cloudflare Pages SPA 的 head 卫生、a11y、死链、供应链
  • 服务端 UI:所有 FastAPI 渲染的 HTML 面,查鉴权跳转一致性、XSS、过期引用
  • 4 个文档验证:每篇文档提取可核查断言(端点、env 名、迁移文件、行为宣称),逐条对照源码,报 VERIFIED-WRONG 带证据

结果先说好的:可用性、CORS、XSS、死域名全绿,约 140 条文档断言里核心 spec 零错误。问题集中在四类,下面挑有信息量的讲。

SSO 跳转不能一刀切#

三个登录态 UI 服务,未登录浏览器访问的行为各不相同:mailbox 返回 303 跳平台 SSO,locationservice 和 displayservice 裸吐 401 JSON。直觉是「统一成 303 跳登录」,但审计 agent 给出了一个关键区分:

  • locationservice 应该跳——它的读 gate 本来就接受 SSO principal,登录回来落地即可用;
  • displayservice 不应该跳——它的 gate 只认 ?key=<viewer token>,根本不读 SSO cookie。跳去登录页再回来,还是 401,死胡同。

所以修复是两条路:把 mailbox 的实现提升进 SDK 成 login_redirect() + wants_html()Accept: text/html 区分浏览器导航和 XHR,后者继续拿机器可读的 401),locationservice 接入;displayservice 则换成一个 HTML 401 页,告诉人类「带 ?key= 访问一次,之后 cookie 记住你」。

# 接入方在 HTML index 路由顶部:
if request.state.principal is None and wants_html(request):
return login_redirect(request)

教训:「统一」之前先确认各服务的鉴权模型是不是同一个。盲目统一会把 viewer-token 服务的用户送进跳转死循环。

Clickjacking:为什么要发两个头#

登录页(auth)和两个特权 SPA(admin/task)之前可以被任意站点 iframe——典型的 clickjacking 面。修复发了两个头:

X-Frame-Options: DENY
Content-Security-Policy: frame-ancestors 'none'

发两个不是冗余:现代浏览器两者都在时按 frame-ancestors 执行、忽略 XFO,老浏览器回退用 XFO,这是 OWASP Clickjacking Cheat Sheet 的标准建议。另一个容易踩的点:frame-ancestors 按规范不能通过 <meta http-equiv> 下发,浏览器会直接忽略——所以这类防护必须落在 header 层:Cloudflare Pages 用构建产物根目录的 _headers 文件,droplet 上的服务落在 Caddy 配置。

CSP 分层:不是所有 mount 都配完整策略#

平台的 Caddy 配置由自建 Deployer 从 Jinja2 模板渲染,所以给全部服务加头只要改模板。但完整 CSP 不能无脑铺:

  • 子域名 UI 服务(display/location/mail)→ 完整 CSP。页面都是 self-contained(共享 page shell、内联样式脚本、同源 fetch),default-src 'self' 直接成立。唯一例外 locationservice 要放行 unpkg 的 Leaflet 和 OpenStreetMap 瓦片,在模板里做了个按服务名的 csp_map
caddy.snippet.j2(节选)
{%- set csp_map = {
"locationservice": "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; ... img-src 'self' data: https://unpkg.com https://tile.openstreetmap.org; ...",
} -%}
header Content-Security-Policy "{{ csp_map.get(name, default_csp) }}"

这个设计是故意的:新服务引 CDN 必须来改模板,否则页面会被 CSP 拦——把「第三方脚本进入登录态 UI」变成一个显式审查点,而不是默认放行。

  • 路径服务和静态站 → 只有防框头 + nosniff,不加完整 CSP。原因具体:resume 服务的 /docs 是 FastAPI 自带的 Swagger UI,从 CDN 加载资源,default-src 'self' 会直接白屏;文件站的上传走 presigned R2 URL,目标域名是运行时配置,渲染模板时根本不知道该往 connect-src 里写什么。锁不住的东西硬锁,坏的是功能不是攻击者。

上线十分钟:看板娘没了#

CSP 推上生产后用 Playwright 打开首页读 console,抓到这个:

[WARNING] live2d init failed: Error: Current environment does not allow
unsafe-eval, please use @pixi/unsafe-eval module to enable support.
at i.systemCheck (https://unpkg.com/oh-my-live2d@0.19.3/dist/index.min.js:...)

首页左下角的 Live2D 看板娘死了。oh-my-live2d 内嵌的 PixiJS 在 shader 编译等路径用 new Function(),严格 CSP 下直接拒绝初始化。官方解法是 @pixi/unsafe-eval 补丁包(v7.1+ import 即生效,v8 要显式引入),但这里的 pixi 是打包在 CDN 整包里的,没有构建环节可以插补丁——只剩权衡:

给 script-src 加 'unsafe-eval'。听起来是开倒车,但算一下实际增量风险:这是个无构建的静态页,本来就绕不开 'unsafe-inline'(没有 nonce 机制),注入的内联脚本可以直接执行,eval 在此之上的边际增量很小。真正扛事的控制是来源列表——script-src 只允许 'self' 和 unpkg,攻击者的 CDN 进不来。

顺带把 unpkg 引用从 @latest 锁到了精确版本。unpkg 对 @latest 的处理是请求时实时解析 npm 的 dist-tag 再重定向,且这个重定向明确不长缓存——意味着包作者(或拿下包的人)下一次发版,代码就带着完整 DOM 和 cookie 权限跑在你的 apex 域上。装饰性的看板娘换任意代码执行,不划算。

两个操作教训:CSP 上线必须带浏览器 console 验证,curl 看头是不够的——策略生效和页面存活是两回事;以及 headless 截图看不出 Live2D 的死活(它在 headless 里本来就不渲染),console 里的 CSP violation 才是可靠信号。

一个反复漏掉的服务#

审计里有个模式值得单独说:mailbox(一周前刚上线的收件箱服务)在 README 的服务表里缺席、在 docs 索引里缺席、在首页卡片里也缺席——三个独立的审计 agent 在三个不相干的地方发现了同一个服务的缺失。

这不是三个 bug,是一个 bug:新服务上线的 checklist 里没有「文档和入口页」这一项。代码、部署、监控都有流程兜着,唯独「让人和 agent 知道它存在」靠自觉。修完之后把「上线新服务跑一次 /docs」写进了项目记忆。

收尾#

三个 push、一次手动 deployer 重渲染,全部上线并逐项验证:SSO 跳转、双防框头、分层 CSP、版本锁定、对比度过 AA、favicon 卫生、workflow 并发守卫。全站 14 个探测点复查健康。

审计这件事的价值密度在交叉印证:单个维度的检查(比如只看安全头)发现的是点,多维度并行发现的是模式——比如「mailbox 到处缺席」和「SSO 行为三家三样」,都是单点检查看不出来的结构性问题。

给自己的平台做全站体检:7 个 agent 并行审计,CSP 上线后看板娘没了
https://blog.lishuyu.app/posts/全站安全审计与csp上线翻车记/
作者
猫猫魔女
发布于
2026-07-11
许可协议
CC BY-NC-SA 4.0