news 2026/9/13 19:50:46

Cherry Studio 迷你应用沙箱的运行时探针:用一次性 Electron 实验固化四条不可从代码推导的安全结论

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cherry Studio 迷你应用沙箱的运行时探针:用一次性 Electron 实验固化四条不可从代码推导的安全结论

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_PRIVILEGESbuildMiniAppCsp()DENY_ALL_PACinstallWebRtcPolicy的改动WebRTC 逃逸
触及net.resolveHostshell/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 帧、发往未声明主机的出站连接,本身就是逃逸——不需要真的拿到中继分配。

搭建。

  1. 主进程:net.createServer()监听0.0.0.0:3478,记录每个连接的远端地址、字节数与头 20 字节(十六进制);再在8443上起一个https.createServer回答ok并带access-control-allow-origin: *(自签名证书现场生成;注意证书那条坑)。
  2. 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 秒
  1. 宿主侧:一个隐藏BrowserWindowwebviewTag: 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 结束。
  2. --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"; }` }
--groupWebRTC 策略session.setProxy白名单回归 fetch
baseline
currentdisable_non_proxied_udp{ mode: 'direct' }
pac-blocked同上主机 PACapi.allowed.example(目标不在名单中)
pac-allowed-port同上主机 PACLAN_IP
pac-regression同上主机 PACLAN_IPhttps://LAN_IP:8443/ok
pac-port-strict同上端口感知 PAC,仅 443LAN_IP
pac-port-strict-regression同上端口感知 PAC,允许 8443LAN_IPhttps://LAN_IP:8443/ok
fetch-control同上https://LAN_IP:8443/ok

回归组之所以允许 8443 而不是 443,仅仅因为绑定 443 需要 root 权限;它证明的是端口守卫确实在按端口区分,而不是"拦一切"。

结果(2026-08-21)。

3478 上的 TCP 命中fetch
baseline2 次(每次 196 B)
current1 次(196 B)
pac-blocked0
pac-allowed-port1 次(196 B)
pac-regression1ok
pac-port-strict0
pac-port-strict-regression0ok

载荷以0003 0008 2112a442开头——一条 STUNAllocate请求(方法0x003、magic cookie0x2112A442):是真实的 TURN 尝试,不是噪声。currentpac-allowed-portpac-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/cancelTransformStream,由一个先入队所有分片、然后要么正常关闭要么报错的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,两者相同)。

终结方式transformflushcancel
finish后正常关闭110
消费者reader.cancel()101
数据源controller.error()001
pipeToAbortSignal中止001

第五条路径——生产者还没返回流就抛错——根本不会创建TransformStream,正确地什么都不跑。

结论,以及落进代码的部分。只挂flush是不够的:三条异常路径都cancel。终结器必须两个都挂,并用一个标志位保证先到者生效、只执行一次。生产代码正是这么做的——protocol.ts 为包文件流创建TransformStream时写了flush: finalize, cancel: finalize,其中finalizefinalized布尔守卫保证文件句柄只关闭一次、读取槽位只释放一次(cancel成员在随包 lib.dom 的Transformer类型里缺失,需要一次 cast)。而 AI 用量账本刻意不采用这套双挂法:ai.ts 中billingHook只在流到达finish时记一行——被取消的调用不留账。这是权衡后的选择:一个任何应用都能用chat+cancel读回零的预算,比没有预算更糟(见 capabilities.md)。

探针 3 — Web Storage 上限

问题。一个迷你应用分区能拿到的原生 Web Storage 有多少?它是每分区独立预算,还是一个池子、允许某个应用把其他应用脚下的份额抽干?这个问题决定了cherry.storage能否被localStorage/ IndexedDB 取代。

搭建。每个分区一个隐藏BrowserWindowsandbox: 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运行内容
singlecom.example.a上普通 CSP,com.example.b上沙箱 CSP
fill单分区,向 IndexedDB 写 300 MB——quota是否大致按写入量下降?
shared-poolc的 estimate → 向hog写 400 MB → 再测d的 estimate

结果(2026-08-23,single)。

测量项数值
localStorageQuotaExceededError的位置52,363,264 B ≈ 49.9 MiB(799 × 64 KB);两遍运行完全一致
回读最后一个分片65,536 字符——写入是真实的
全新分区上estimate().quota36,425,928,704 – 36,442,320,896 B ≈33.9 GiB
当时磁盘剩余33.9 GB
navigator.storage.persisted()true——特权 scheme 上自动授予;压力下不会被驱逐
灌满localStorageestimate().usage仍是0——localStorage不计入

两个互不相干的新分区各自"承诺"了约 33.9 GiB,而磁盘总共只有 33.9 GB:同一块空间被承诺了两次。上限是从剩余磁盘空间推导出来的,不存在任何按应用划分的预算——这是一个被超额售卖的共享池。

没有跑完的一半。"灌满再重测"实验(fillshared-pool)从未干净跑通:单进程里第二个窗口因上文的双protocol.handle坑报ERR_FAILED,另一套双分区编排则死在SIGTRAP。"共享池"的结论是从两条承诺值推断出来的,并非直接测量。要重建这一半,必须让 hog 与 observer 作为独立的 Electron 进程跑在同一个userData上,并且必须包含正对照——一个把每次写入都丢掉的实现同样会让 observer 的 quota 不变。

代码中的后果。cherry.storagecherry.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.cckMinimumTTLSeconds)——如果缓存条目能共享,连接就会被钉死在已检查过的答案上。

搭建。一个仅主进程的 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.comCREATE_JOB
net.resolveHost('example.com')example.com:0CREATE_JOB
紧随其后的net.fetch('https://example.com/')https://example.comCREATE_JOB——又解析了一遍
net.resolveHost('example.com:443')net.resolveHost('https://example.com')[example.com:443]:0[https://example.com]:0ERR_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.localStorageWeb Storage 的拒绝靠 CSPsandbox(它会传播),而不是靠剥 API(protocol.ts)
frame-src不覆盖srcdoc/about:blank子框架frame-src 'none',再建<iframe srcdoc="…">——它能渲染同上:围堵必须是继承的,而不是按 URL 指定的
在 CSPsandbox下,fetch 应用自己的文件会以跨域失败,除非 scheme 声明corsEnabled且响应给出access-control-allow-originsandbox allow-scripts投递一个页面,fetch('./a.txt')MINI_APP_PRIVILEGES.corsEnabled: true,以及每个响应上的 ACAO 头
未带standard: true注册的 scheme 产生不透明 origin,且丢失相对 URL 解析不带该标志注册,用相对<script src>加载cherry-miniapp://id/index.htmlMINI_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 19:49:23

Proteus 8.17稳定安装指南:ARM仿真与LIC绑定实战

1. 为什么Proteus 8.17值得花时间认真装好——不是“能用就行”&#xff0c;而是“用得稳、仿得真、改得快”Proteus 8.17不是随便点几下就能跑起来的普通软件&#xff0c;它是电子工程师日常工作中真正扛活的仿真平台。我带过十几届学生做单片机课程设计&#xff0c;也帮三家公…

作者头像 李华
网站建设 2026/9/13 19:44:26

Argo CD 如何执行部分资源的选择性同步(Selective Sync)

Argo CD 如何执行部分资源的选择性同步&#xff08;Selective Sync&#xff09; 【免费下载链接】argo-cd Declarative Continuous Deployment for Kubernetes 项目地址: https://gitcode.com/GitHub_Trending/ar/argo-cd 当一次同步只想作用于 Application 中的部分资源…

作者头像 李华