Cherry Studio 迷你应用沙箱的运行时探针:用一次性 Electron 实验固化四条不可从代码推导的安全结论
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
Cherry Studio 的迷你应用(mini app)沙箱并非只靠"写出来的防御代码"成立——它的多层网络隔离、Web Storage 禁用与文件流终结器,都建立在若干负向实验结论之上:某条 CSP 指令毫无效果、某种代理模式拦不住 WebRTC、某个流回调永远不会触发。这些结论无法从源码重新推导,只能靠针对当前 Electron 版本跑一次性探针程序实测。本文完整继承 docs/references/mini-app/probes.md 的四组探针设计与全部实测数据,并结合 network.ts、protocol.ts、webviewHost.ts 等源码,说明每条结论最终如何落进生产代码。读完后你能掌握:如何在 Electron/Chromium 大版本升级后按记录重建探针、每条沙箱防线背后对应的实测证据,以及探针设计中"正/负对照组"的方法论。
为什么需要运行时探针
探针程序本身是用完即弃的——仓库并不保存这些脚本,probes.md 这份文档就是记录本身,其目标很明确:当 Electron 或 Chromium 发生大版本变动、某个结论失效时,工程师可以在一小时内按文档重建对应探针并重新测量。
所有数字的基准环境为Electron 41.8.0 / Chrome 146.0.7680.216 / macOS 26.5。文档要求:任何新运行应在对应探针下追加(append)一条新记录,而不是修改旧数字——这样一次回归在文档里就表现为一个清晰的 diff,而不是被悄悄覆盖的历史。
何时需要重跑探针
| 触发条件 | 需要重跑的探针 |
|---|---|
| Electron 或 Chromium 大版本升级 | WebRTC 逃逸(必须连同baseline组一起跑)、Web Storage 上限 |
| Electron 内置 Node 大版本升级 | TransformStream 回调 |
任何对MINI_APP_PRIVILEGES、buildMiniAppCsp()、DENY_ALL_PAC或installWebRtcPolicy的改动 | WebRTC 逃逸 |
触及net.resolveHost(shell/browser/net/resolve_host_function.cc)的 Electron 升级 | Host-cache 复用 |
共享脚手架:复刻生产投递路径
每个探针都是一个纯 CommonJS 文件,用node_modules/.bin/electron probe.js直接运行,无构建步骤。关键点在于它复刻了生产环境的投递路径:一个带特权声明的cherry-miniapp://scheme、一个persist:miniapp:<appId>分区(partition),以及在每个protocol.handle响应上附加 CSP(生产中 protocol.ts 做一次,network.ts 再通过webRequest.onHeadersReceived做第二次):
const { app, BrowserWindow, protocol, session } = require('electron') protocol.registerSchemesAsPrivileged([{ scheme: 'cherry-miniapp', privileges: { standard: true, secure: true, supportFetchAPI: true, bypassCSP: false, allowServiceWorkers: false, corsEnabled: true } }]) // after app.whenReady(): const sess = session.fromPartition('persist:miniapp:com.example.probe') sess.protocol.handle('cherry-miniapp', (req) => new Response(html, { headers: { 'content-type': 'text/html', 'access-control-allow-origin': '*', 'content-security-policy': "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'" } }))踩坑清单(每一条都花掉过真实时间)
| 坑 | 症状 | 规则 |
|---|---|---|
protocol.handle('cherry-miniapp:') | 加载失败ERR_FAILED (-2),handler 根本不被调用 | scheme 注册不带尾部冒号 |
同一个 session 上protocol.handle两次 | 抛异常,并在下一个窗口的加载时表现为ERR_FAILED | 每个分区只注册一次,并记住这一点 |
通过window.__result+executeJavaScript回传结果 | 在contextIsolation下挂起——隔离世界之间不共享window | 通过 DOM(document.getElementById('r').textContent)或console-message上报 |
console.log(result)后紧跟app.exit() | 已完成的运行什么都不打印——stdout 到管道是异步的,缓冲区被丢弃 | 用fs.writeFileSync写结果,或提前打印并延迟退出 |
从 guest 里 fetch 普通http://目标 | 在secure: true的 scheme 上作为混合内容被拦 | 回归目标必须是https://;并且每组都要加sess.setCertificateVerifyProc((_r, cb) => cb(0)),否则无代理对照组会栽在证书上,被误读成代理结果 |
| Guest fetch 白名单主机失败 | 不透明 origin 文档(CSPsandbox)使每个请求都变成跨域 | 目标必须回答access-control-allow-origin: *,且 CSPconnect-src必须点名该主机——否则一个被拦的 fetch 对代理证明不了任何事 |
按webContents逐个装策略 | 在 guest 存在之前调用setWebRTCIPHandlingPolicy没有任何作用 | 在did-attach-webview内应用;session 级配置(setProxy,需 await)要在 webview 创建之前完成 |
探针 1 — WebRTC 逃逸(网络围堵)
问题。一个没有声明任何网络权限的 guest,能否打开任意出站连接?最初的一次尝试用"未配置 TURN 服务器时的 ICE candidate 数量"来测量——这测量的是"没什么可收集",而不是"什么都连不出去"。真正的判据是我们控制的一个 TCP 套接字到底有没有被访问:一个携带 TURN 帧、发往未声明主机的出站连接,本身就是逃逸——不需要真的拿到中继分配。
搭建。
- 主进程:
net.createServer()监听0.0.0.0:3478,记录每个连接的远端地址、字节数与头 20 字节(十六进制);再在8443上起一个https.createServer回答ok并带access-control-allow-origin: *(自签名证书现场生成;注意证书那条坑)。 - Guest 页面(用上面的 CSP 加
connect-src https://<LAN_IP>:8443投递):
const pc = new RTCPeerConnection({ iceServers: [{ urls: `turn:${LAN_IP}:3478?transport=tcp`, username: 'probe', credential: 'probe' }], iceTransportPolicy: 'relay' // host/srflx 候选会让"我们拿到了候选"这一信号变得含糊 }) pc.createDataChannel('probe') pc.onicecandidate = (e) => e.candidate && result.candidates.push(e.candidate.candidate) await pc.setLocalDescription(await pc.createOffer()) // 等待 iceGatheringState === 'complete' 或 8 秒- 宿主侧:一个隐藏
BrowserWindow(webviewTag: true),挂上<webview partition="persist:miniapp:…" src="cherry-miniapp://com.example.probe/index.html">。在did-attach-webview里:应用该组的 WebRTC 策略、等加载完成、按组需要执行回归fetch、启动 guest 脚本、等 9 秒、读回 guest 结果与监听器命中、打印PROBE_RESULT {…}并退出。30 秒超时则打印已有内容并以退出码 1 结束。 - 传
--lan=<本机 LAN IP>;用回环地址会让代理结果失去意义。
分组。pac-*组使用的白名单 PAC:
function allowlistPac(hosts, portStrict, allowedPort = '443') { // PAC 的 `url` 参数携带非默认端口——这是唯一能执行 443 规则的地方, // 因为 webRequest 永远看不到 TURN 连接。 const portGuard = portStrict ? `var m = url.match(/^[a-z]+:\\/\\/[^\\/]*?:(\\d+)/); if (m && m[1] !== "${allowedPort}") return "PROXY 127.0.0.1:1";` : '' return `function FindProxyForURL(url, host) { ${portGuard} if (${JSON.stringify(hosts)}.indexOf(host) !== -1) return "DIRECT"; return "PROXY 127.0.0.1:1"; }` }--group | WebRTC 策略 | session.setProxy | 白名单 | 回归 fetch |
|---|---|---|---|---|
baseline | 无 | 无 | — | — |
current | disable_non_proxied_udp | { mode: 'direct' } | — | — |
pac-blocked | 同上 | 主机 PAC | api.allowed.example(目标不在名单中) | — |
pac-allowed-port | 同上 | 主机 PAC | LAN_IP | — |
pac-regression | 同上 | 主机 PAC | LAN_IP | https://LAN_IP:8443/ok |
pac-port-strict | 同上 | 端口感知 PAC,仅 443 | LAN_IP | — |
pac-port-strict-regression | 同上 | 端口感知 PAC,允许 8443 | LAN_IP | https://LAN_IP:8443/ok |
fetch-control | 同上 | 无 | — | https://LAN_IP:8443/ok |
回归组之所以允许 8443 而不是 443,仅仅因为绑定 443 需要 root 权限;它证明的是端口守卫确实在按端口区分,而不是"拦一切"。
结果(2026-08-21)。
| 组 | 3478 上的 TCP 命中 | fetch |
|---|---|---|
baseline | 2 次(每次 196 B) | — |
current | 1 次(196 B) | — |
pac-blocked | 0 | — |
pac-allowed-port | 1 次(196 B) | — |
pac-regression | 1 | ok |
pac-port-strict | 0 | — |
pac-port-strict-regression | 0 | ok |
载荷以0003 0008 2112a442开头——一条 STUNAllocate请求(方法0x003、magic cookie0x2112A442):是真实的 TURN 尝试,不是噪声。current、pac-allowed-port、pac-port-strict各跑了两遍,结果完全一致。
结论,以及落进代码的部分。
| 发现 | 代码中的后果 |
|---|---|
disable_non_proxied_udp+{ mode: 'direct' }是泄漏的:该策略只禁止"非代理的 UDP",公共接口上的 TCP 是明确允许的,且setWebRTCIPHandlingPolicy+setWebRTCUDPPortRange就是 Electron 全部的 WebRTC 控制面 | installWebRtcPolicy被保留为故事的一半,绝不单独使用(见 network.ts) |
仅按主机的 PAC 把"对某一声明主机的 443 HTTPS"放宽成"对该主机的任意端口"——一条webRequest永远看不到的双向通道 | 生产环境不用白名单 PAC |
| 端口感知 PAC 能堵上这个口子,且白名单流量照常工作 | 同样没有上线:guest 流量走的是主进程的cherry.network.fetch,因此该 session 直接用DENY_ALL_PAC——所有 URL 指向127.0.0.1:1,同一结论的严格更强形式。白名单分组留在探针里,作为杀死白名单设计的证据,而不是蓝图 |
CSP 与webRequest都观测不到 TURN 连接;CSPwebrtc 'block'无效;setPermissionRequestHandler对 WebRTC 从不被调用 | 代理是唯一能拒绝 WebRTC 的层,也是这个探针唯一能检测到回归的层 |
对照组设计。baseline是正对照——没有它,到处"0 命中"会被读成"修好了",而实际上可能是监听器坏了。pac-regression/pac-port-strict-regression是负对照——一个拦一切的 PAC 同样能满足所有"0 命中"行,却把产品搞坏了。fetch-control单独校验自签名证书路径。重建探针时若少了这三种对照,跑出来的数字毫无意义。
对照源码可以看到这套结论如何落地:network.ts 中installWebRtcPolicy只做setWebRTCIPHandlingPolicy('disable_non_proxied_udp'),注释明确写着"这是 WebRTC 故事的一半,绝不单独使用";DENY_ALL_PAC则是一条无条件把所有 URL 送进死代理127.0.0.1:1的 PAC,通过await session.setProxy({ pacScript: ... })安装——注意是awaited的,因为代理在setProxyresolve 之前不生效,而 guest 可能在那之前就已 attach。
探针 2 — TransformStream 终态回调
问题。一条被管道接起的流在每一种结束方式下,分别会触发哪些TransformStream回调?这决定了一个挂在流上的终结器(关闭文件句柄、释放读取槽位、计费一次调用)能否只依赖flush。
搭建。一个计数transform/flush/cancel的TransformStream,由一个先入队所有分片、然后要么正常关闭要么报错的ReadableStream供数,走四条终结路径。在 Node 下跑一遍(node probe.mjs),再在 Electron 里跑一遍(app.whenReady().then(…),打印process.versions.node)——内置 Node 可能不同:
const counts = { transform: 0, flush: 0, cancel: 0 } const ts = new TransformStream({ transform(c, ctrl) { counts.transform++; ctrl.enqueue(c) }, flush() { counts.flush++ }, cancel() { counts.cancel++ } }) const src = (parts, err) => new ReadableStream({ start(c) { parts.forEach((p) => c.enqueue(p)); err ? c.error(new Error('boom')) : c.close() } }) // 1 正常: await src(['a']).pipeThrough(ts).pipeTo(new WritableStream()) // 2 消费者: const r = src(['a','b']).pipeThrough(ts).getReader(); await r.read(); await r.cancel() // 3 数据源: await src(['a'], true).pipeThrough(ts).pipeTo(new WritableStream()).catch(() => {}) // 4 中止: const ac = new AbortController(); const p = src(['a']).pipeThrough(ts).pipeTo(new WritableStream(), { signal: ac.signal }); ac.abort(); await p.catch(() => {}) // 然后等约 50 ms 再读计数结果(Node 24.14.0 与 Electron 41.8.0 内置 Node 24.16.0,两者相同)。
| 终结方式 | transform | flush | cancel |
|---|---|---|---|
finish后正常关闭 | 1 | 1 | 0 |
消费者reader.cancel() | 1 | 0 | 1 |
数据源controller.error() | 0 | 0 | 1 |
pipeTo被AbortSignal中止 | 0 | 0 | 1 |
第五条路径——生产者还没返回流就抛错——根本不会创建TransformStream,正确地什么都不跑。
结论,以及落进代码的部分。只挂flush是不够的:三条异常路径都只跑cancel。终结器必须两个都挂,并用一个标志位保证先到者生效、只执行一次。生产代码正是这么做的——protocol.ts 为包文件流创建TransformStream时写了flush: finalize, cancel: finalize,其中finalize用finalized布尔守卫保证文件句柄只关闭一次、读取槽位只释放一次(cancel成员在随包 lib.dom 的Transformer类型里缺失,需要一次 cast)。而 AI 用量账本刻意不采用这套双挂法:ai.ts 中billingHook只在流到达finish时记一行——被取消的调用不留账。这是权衡后的选择:一个任何应用都能用chat+cancel读回零的预算,比没有预算更糟(见 capabilities.md)。
探针 3 — Web Storage 上限
问题。一个迷你应用分区能拿到的原生 Web Storage 有多少?它是每分区独立预算,还是一个池子、允许某个应用把其他应用脚下的份额抽干?这个问题决定了cherry.storage能否被localStorage/ IndexedDB 取代。
搭建。每个分区一个隐藏BrowserWindow(sandbox: true, contextIsolation: true),通过上述 scheme 投递,带或不带生产 CSP(sandbox allow-scripts强制不透明 origin)。Guest 以 64 KB 分片向localStorage写入直到抛错,回读最后一个分片(否则被静默丢弃的写入会被误读成"上限很大"),可选地向 IndexedDB 写 N × 1 MB,并报告写入前后的navigator.storage.estimate()与navigator.storage.persisted()。结果经console-message回传(RESULT {…})——在所有executeJavaScript跨分区轮询都不可靠的运行中,它是唯一每次都送达的通道。主进程同时记录fs.statfsSync(userData)的磁盘剩余空间。
--group | 运行内容 |
|---|---|
single | com.example.a上普通 CSP,com.example.b上沙箱 CSP |
fill | 单分区,向 IndexedDB 写 300 MB——quota是否大致按写入量下降? |
shared-pool | 测c的 estimate → 向hog写 400 MB → 再测d的 estimate |
结果(2026-08-23,single)。
| 测量项 | 数值 |
|---|---|
localStorage抛QuotaExceededError的位置 | 52,363,264 B ≈ 49.9 MiB(799 × 64 KB);两遍运行完全一致 |
| 回读最后一个分片 | 65,536 字符——写入是真实的 |
全新分区上estimate().quota | 36,425,928,704 – 36,442,320,896 B ≈33.9 GiB |
| 当时磁盘剩余 | 33.9 GB |
navigator.storage.persisted() | true——特权 scheme 上自动授予;压力下不会被驱逐 |
灌满localStorage后estimate().usage | 仍是0——localStorage不计入 |
两个互不相干的新分区各自"承诺"了约 33.9 GiB,而磁盘总共只有 33.9 GB:同一块空间被承诺了两次。上限是从剩余磁盘空间推导出来的,不存在任何按应用划分的预算——这是一个被超额售卖的共享池。
没有跑完的一半。"灌满再重测"实验(fill、shared-pool)从未干净跑通:单进程里第二个窗口因上文的双protocol.handle坑报ERR_FAILED,另一套双分区编排则死在SIGTRAP。"共享池"的结论是从两条承诺值推断出来的,并非直接测量。要重建这一半,必须让 hog 与 observer 作为独立的 Electron 进程跑在同一个userData上,并且必须包含正对照——一个把每次写入都丢掉的实现同样会让 observer 的 quota 不变。
代码中的后果。cherry.storage与cherry.file保持为唯一的持久化手段,配额由宿主选定(quota.ts);CSPsandbox指令是唯一被测量确认能拒绝 Web Storage 的机制,且其标志会传播进嵌套上下文(protocol.ts)。
探针 4 —net.resolveHost的 host-cache 复用
问题。cherry.network.fetch里的私有地址检查,能否与 Chromium 实际建连共享同一份 DNS 答案,从而关闭"先解析后连接"的时间窗(DNS rebinding)?dns.promises.lookup与 Chromium 是各自独立解析的。而net.resolveHost走的是 Chromium 自己的 host cache,其正向 TTL 被钳制在 60 秒下限(net/dns/host_resolver_manager_job.cc的kMinimumTTLSeconds)——如果缓存条目能共享,连接就会被钉死在已检查过的答案上。
搭建。一个仅主进程的 Electron 程序,以--log-net-log启动:先net.fetch一个对照主机(预期 miss),然后立即net.resolveHost(H),紧接net.fetch('https://H/')。按请求来源区分,net-log 里的HOST_RESOLVER_MANAGER_*事件以CREATE_JOB(一次全新解析)或CACHE_HIT收尾。
结果(2026-08-26)。
| 请求 | Net-log 键 | 结果 |
|---|---|---|
对照net.fetch('https://www.cloudflare.com/') | https://www.cloudflare.com | CREATE_JOB |
net.resolveHost('example.com') | example.com:0 | CREATE_JOB |
紧随其后的net.fetch('https://example.com/') | https://example.com | CREATE_JOB——又解析了一遍 |
net.resolveHost('example.com:443')、net.resolveHost('https://example.com') | [example.com:443]:0、[https://example.com]:0 | ERR_NAME_NOT_RESOLVED——被当成字面名字 |
发现。Electron 以HostPortPair(host, 0)加空NetworkAnonymizationKey发出请求(shell/browser/net/resolve_host_function.cc);host cache 以 scheme-host-port 为键,因此resolveHost产生的任何条目,都不会是https://fetch 读取的那一条。事后复核也不可行:webRequest事件里只有onBeforeRedirect携带ip,而net.fetch不暴露 socket。
代码中的后果。capabilities/network.ts 保留dns.promises.lookup作为预检(解析出的任何地址若落入isPrivateAddress的私有段黑名单即拒绝),resolve-then-connect 时间窗作为已知残余风险记录在 capabilities.md 中;从源码看,要钉住答案需要一套自带lookup的 Node 侧 HTTP 栈,而那会放弃 session 的代理与证书处理,得不偿失。
探针未保留、但结论已确立的若干发现
以下发现同样以"生产 scheme + CSP 下的一次性页面"方式确立,但太小、不值得留成独立程序。每一条都是一份单文件复验清单:
| 发现 | 复验方法 | 代码中的后果 |
|---|---|---|
在顶层文档里删掉window.localStorage/indexedDB,在<iframe srcdoc>或about:blank里会被撤销——子框架拿到的是全新全局对象 | 在父文档剥掉 API,创建子框架,在那里读window.localStorage | Web Storage 的拒绝靠 CSPsandbox(它会传播),而不是靠剥 API(protocol.ts) |
frame-src不覆盖srcdoc/about:blank子框架 | 设frame-src 'none',再建<iframe srcdoc="…">——它能渲染 | 同上:围堵必须是继承的,而不是按 URL 指定的 |
在 CSPsandbox下,fetch 应用自己的文件会以跨域失败,除非 scheme 声明corsEnabled且响应给出access-control-allow-origin | 用sandbox allow-scripts投递一个页面,fetch('./a.txt') | MINI_APP_PRIVILEGES.corsEnabled: true,以及每个响应上的 ACAO 头 |
未带standard: true注册的 scheme 产生不透明 origin,且丢失相对 URL 解析 | 不带该标志注册,用相对<script src>加载cherry-miniapp://id/index.html | MINI_APP_PRIVILEGES中的standard: true |
did-attach-webview触发时,guest 的 URL 仍为空 | 在 handler 里打contents.getURL()日志 | 宿主从 guest 的session/分区解析应用,永远不从 URL 解析(webviewHost.ts 的resolveAppIdBySession) |
setPermissionRequestHandler对 WebRTC 从不被咨询,CSPwebrtc 'block'在 Electron 41 上无效 | 探针 1 的baseline组,两者同时装上 | 两个 handler 为媒体/地理位置保留;WebRTC 仅由代理层拒绝(network.ts) |
小结:把"负向知识"变成可维护的工程资产
这份探针文档的价值不在四个数字本身,而在三点可复用的方法论。其一,判据先行:探针 1 最初用 ICE candidate 数量作为可观测信号,被识别为"测错了对象"后才换成"我们控制的 socket 是否被访问"——先想清楚可观测量的语义,再动手。其二,对照组即设计的一部分:正对照(baseline)区分"修好了"与"没测到",负对照(pac-regression系列)区分"拦住了"与"全拦了",缺一则整张结果表失效。其三,文档即程序:探针脚本被有意丢弃,但记录保留了环境基线、踩坑清单、分组矩阵与复验步骤,使每条结论的"保鲜期"绑定到明确的触发条件(探针重跑条件表)上,回归以 diff 而非重写的方式暴露。对维护者而言,这意味着升级 Electron 后不需要重新论证沙箱为什么这样设计——打开这份文档,按对应触发条件重跑对应探针即可。
【免费下载链接】cherry-studioAI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs项目地址: https://gitcode.com/GitHub_Trending/ch/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考