外观
人机验证:自建 Cap 产物
回答的问题:为什么验证码组件是自己从上游模板构建的、界面与解题器的分界画在哪、 为什么这个分界不能挪。
组件位置:ui/vendor/cap/(解题器,vendored)+ ui/forms/CapCheck.svelte(界面)。 服务端是自托管的 Cap 实例 https://challenges.natlan.io。 升级步骤见 guides/upgrade-cap-widget.md。
1. 为什么不用 npm 发布产物
来自 @cap.js/widget(Apache-2.0)。上游的包里同时发了压缩产物和构建模板 (src/cap.js,带 %%…%% 占位符)。项目用模板自建,有两个理由:
零第三方源。 压缩产物在运行时会 fetch() jsDelivr 上的 WASM 解题器,另有一条 老浏览器兜底路径会插一个指向 jsDelivr 的 <script> 去拉 pako。两处都改成了本地资源, 域外请求因此只剩 challenges.natlan.io。这是这个页面能收得住 CSP 的前提。
界面归项目所有。 上游把 UI 封在 shadow root 里并带自我强制逻辑,用压缩产物时只能 从外面打 !important 补丁去对抗它。
⚠️ 不要改回
import '@cap.js/widget'。那份产物的运行时取数会让 CSP 立刻失守。
2. 分界:解题器留上游,界面是自己的
vendored 的这份只保留解题一侧——出题/兑换协议、WASM 装载、worker 池、预解题、 instrumentation 沙箱。上游的 UI(shadow root、样式表、复选框与进度环、"Cap" 字标) 整段删除,不是留着不用:留着会让人读到一处 DOM 写入就以为它上了屏。
两边的接口只有一个 el.capUI 回调:
js
el.capUI = (view) => { /* { state, label, busy, troubleshootUrl, warning } */ };
el.solve(); // 由界面的按钮触发
el.reset(); // 票是一次性的,用完重取progress / solve / error / reset 仍是元素上的 DOM 事件,界面组件直接监听。
分界为什么画在这里:解题那一半是与两个不受控对象的契约——自建 Cap 服务端的 出题格式(2 种格式 × 3 种协议)和 @cap.js/wasm 的 wasm-bindgen ABI。自己重写就要 长期维护一份必须逐位兼容的验证码协议实现;保持上游形状,则上游的安全修复一条 npm run vendor:cap 就能跟上。界面那一半没有这种约束,自绘反而消掉了样式对抗。
3. <cap-widget> 元素的三条硬约束
这些是自定义元素本身的性质,不是可选做法:
- 元素必须留在 DOM 里,且不能
display:none。它承载表单用的隐藏 token 字段, 而内部的#isVisible()会用它决定要不要提前预解题。界面把它渲染成零尺寸的兄弟节点。 - 不能用它包住自绘界面。
connectedCallback会用#host.innerHTML = …覆盖自己的 light DOM,框架放进去的东西会被抹掉。 - 需要 upgrade own properties。元素可能在定义它的模块落地前就在 DOM 里,宿主早早 赋的
el.capUI = fn会变成实例自有属性并永久遮蔽原型 setter。这是自定义元素的 标准处理,上游没做(它本来也没有 setter),是本地补的。
4. 本地改动清单
重新 vendor 后必须逐条复核;源码里都标了 LOCAL CHANGE。
| 改动 | 位置 | 说明 |
|---|---|---|
| WASM 走本地资源 | cap.js getWasmModule() | 上游默认 cdn.jsdelivr.net/npm/@cap.js/wasm@<ver>;改成 @cap.js/wasm 包里的 ?url 资源。版本靠 package.json 对齐,升级 widget 时要一起核对 WASM_VERSION |
| pako 兜底走本地资源 | cap.js _inflateRaw() | 只有不支持 DecompressionStream 的老浏览器会拉;?url 资源,不进主 chunk |
| 删光 UI | cap.js:createUI / animateLabel / shadow root / #div #trigger #troubleshootLink / trigger 上的三个事件 / handleProgress 的进度环与标签写入 / invalid 时的滚动与抖动 / #credits 与 #enforceCredits / 4 处 aria-label 写入;cap.css 整个文件删除 | updateUI 与 updateUIBlocked 只剩一句 #emitUI。删字标顺带去掉了那个把本站 hostname、完整 URL、referrer 当参数发去 trycap.dev 的点击跳转 |
加 capUI 出口 | cap.js set capUI / #emitUI / #uiView | 上游没有对外暴露 UI 状态的口子,这是新增的唯一 API |
| upgrade own properties | cap.js constructor | 见第 3 节 |
| 去掉 CommonJS/AMD 尾巴 | cap.js 末尾 | 现在是 ES 模块;导入即注册 <cap-widget>,运行时无导出 |
| 类型只留类型 | cap.d.ts | Cap 类与 default 导出已删;新增 CapView |
worker.js 与 cap.d.ts 的其余部分逐字未改。
还有一处与上游产物的差异不是改动、是构建方式:上游用 terser 先把 worker 压好再塞进 模板,这里塞的是原文(它在产物里是字符串字面量,Vite 的压缩管不到),约多 6KB 未压缩、 gzip 后差得很少。换来的是 worker.js 在仓里可读可改。
5. 这个组件对 CSP 提出的要求
四条,缺一条就失败,且失败形态都不直观。完整策略与两站的投递方式见 reference/csp.md。
| 指令 | 为什么 |
|---|---|
script-src 'unsafe-eval' | 服务端下发的 instrumentation 脚本用 eval / Function |
script-src 'nonce-…' + window.CAP_SCRIPT_NONCE | 该脚本在 sandbox="allow-scripts" 的 srcdoc iframe 里内联执行,srcdoc 继承父文档策略而 'self' 匹配不上,只有 nonce 放得行 |
worker-src blob: | 解题 worker 是 Blob URL |
connect-src https://challenges.natlan.io | 出题 / 兑换;WASM 资源已是同源 |
'unsafe-eval' 缺失的故障形态最难查:脚本静默死在沙箱 iframe 里,20 秒后超时、 登录失败,而主文档一条违规都不报——违规事件发生在那个 opaque origin 的子文档里。 2026-08-10 线上实测确认过:不给就是 securitypolicyviolation script-src eval → Instrumentation timeout,给了就解题成功。'unsafe-eval' 同时覆盖 WebAssembly.compile() 需要的 'wasm-unsafe-eval'。
nonce 必须是十六进制。 Cap 的 shadow-DOM <style nonce=…> 属性不带引号,base64 的 = padding 会把属性截断。
style-src 现在与 Cap 无关:界面是项目自己的组件、样式走构建产物的外链 stylesheet,上游那个 shadow root 里的内联 <style> 已经不存在。CAP_CSS_NONCE 在 源码里只剩一个用途——CAP_SCRIPT_NONCE 缺席时的兜底。
6. 前端不校验票据
Cap 票是一次性的。前端只负责渲染控件并把 token 随请求发出,校验完全在服务端 (持有 CAP_SECRET_KEY,调该实例的 /siteverify)。前端做预校验会烧掉 token。 每次登录失败前端会自动 reset() 控件重新取票。
Cap 实例的 CORS 白名单需含所有消费方来源(console.natlan.io、auth.natlan.io 及本地开发来源)。