Cherry Studio Mini App 沙箱解析:不透明源、默认拒绝网络与逐项替代方案
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
迷你应用(Mini App)在 Cherry Studio 中是一个静态 Web 页面,但它并不运行在普通网页的运行时环境里:它拥有不透明源(opaque origin)、完全无网络、零浏览器权限。本文以 Sandbox 参考文档 为核心,结合 runtime 目录 的源码实现,系统讲解沙箱环境的每个限制、底层遏制机制、以及"被阻止后该用什么替代"的完整对照,帮助开发者在编写 mini app 时避免"Chrome 里能跑、沙箱里失败"的踩坑循环。
一、先读这一节:你的代码为什么在这里会"失灵"
一个 mini app 本质上是一个 Web 页面,但它的运行位置与常规 Web 页面完全不同。根据 sandbox.md 的环境定义,其运行时具有以下硬性属性:
| 属性 | 值 |
|---|---|
| URL | cherry-miniapp://<appId>/<path>;/会加载index.html,其余路径对应包内的文件 |
| Origin | 不透明(Opaque)——每个响应都套用了 CSPsandbox allow-scripts;location.origin恒为"null" |
| Node / Electron | 不存在。require、process、ipcRenderer均未注入,唯一的宿主面是window.cherry |
| Content Security Policy | sandbox allow-scripts; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none' |
| 页面侧网络 | 任何不属于自身包的请求,在离开进程之前就会被取消,与 manifest 中声明的网络主机无关 |
| Chromium 权限 | 所有权限请求(Notification、地理位置、摄像头、麦克风、剪贴板、MIDI、USB、蓝牙、屏幕捕获)一律被拒绝 |
这段话的结论非常直接:"在 Chrome 里跑得好好的代码"在这里会以各种看似代码 bug 的方式失败。理解并接受这套环境约束,是编写 mini app 的第一课。后续所有章节都可以看作这张表的展开与实现细节。
二、三层网络遏制:从源码看沙箱是怎么"关门"的
沙箱对网络的控制并不是靠一条规则实现的。查看 network.ts 的头部注释,可以清晰地看到它是三个必须同时存在的层:
webRequest:Chromium 网络栈内部的硬边界;- CSP:纵深防御,并在 DevTools 中给出可读的失败原因;
- PAC(Proxy Auto-Config):唯一能"看见" WebRTC TURN 连接的层。
源码注释里还点明了为什么不能只用其中一层:单靠 CSP由运行着第三方代码的渲染进程执行;单靠 webRequest则会把每一次失败都变成不透明的错误。三者的职责划分如下。
2.1 第一层:webRequest 请求过滤器
shouldAllowRequest(url, appId)(network.ts)是唯一的放行规则:
export function shouldAllowRequest(url: string, appId: string): boolean { let parsed: URL try { parsed = new URL(url) } catch { return false } if (parsed.protocol === 'devtools:') return true return parsed.protocol === `${MINI_APP_SCHEME}:` && parsed.host === appId }两个关键细节:
- 不是
startsWith:cherry-miniapp://com.example.a.evil与cherry-miniapp://com.example.a共享字符串前缀,但绝不能放行,所以比较的是"协议 === 自定义 scheme 且 host === 自身 appId"; devtools:被单独放行:DevTools 的前端运行在 guest 的 session 里,但它本身是特权 scheme、Web 页面无法访问,拒绝它只会让 DevTools 窗口空白。
installNetworkPolicy(network.ts)随后把这条规则安装到 guest 的 session 上:onBeforeRequest对每个请求执行判定并cancel掉不允许的请求;onHeadersReceived再给每个响应追加一遍 CSP(protocol.ts已发送过一次,这里双保险,保证沙箱不依赖"自定义 scheme 是否走 webRequest"这一实现细节)。
2.2 第二层:CSP 内容安全策略
完整的 CSP 字符串由buildMiniAppCsp()统一生成(protocol.ts),它是全仓库唯一的 CSP 来源。需要重点理解两个指令:
sandbox allow-scripts:这是让文档变成不透明源的机制,也是唯一被实测确认能封死 localStorage / IndexedDB 的手段,并且它的标志会传播到嵌套上下文,顺带封住了srcdoc/about:blank的再进入路径;connect-src 'none':该层完全不做白名单。任何远端字节只能通过cherry.network.fetch进入,并以脚本构造的data:URL 形式到达页面——这正是"页面不能直连网络"与"宿主可以代理网络"两个事实同时成立的原因。
同时,MINI_APP_PRIVILEGES(protocol.ts)中standard: true是必须的:没有它,自定义 scheme 会失去相对 URL 解析能力,且 Chromium 会直接禁用 localStorage / indexedDB;bypassCSP保持false(CSP 是两层网络遏制之一);allowServiceWorkers保持false(Service Worker 会在导航后存活,必须禁用);corsEnabled: true则保证不透明源也能 fetch 自身包(见第五节)。
2.3 第三层:PAC 与 WebRTC 拦截
connect-src 'none'和webRequest都看不见 WebRTC 的连接。源码注释明确记录了实测结论(network.ts):webRequest处理器永远不会为 WebRTC 触发,CSP 的webrtc 'block'在 Electron 41 上无效。因此 WebRTC 需要专门的两件套:
installWebRtcPolicy(contents)(network.ts):contents.setWebRTCIPHandlingPolicy('disable_non_proxied_udp'),切断 WebRTC 的 UDP 直连路径——这只是一半,单独使用时会留下"TCP 仍可直连未声明主机并发出 STUN Allocate 报文"的逃逸口(探针 1 的实测记录见 probes.md);DENY_ALL_PAC(network.ts):return "PROXY 127.0.0.1:1"——所有流量被指向一个故意不可路由的死代理,任何发往真实代理的 TURN 连接都会失败。
为什么 PAC 不做白名单而是全量拒绝?network.ts 的注释给出了实测依据:一个"仅主机"的 PAC 规则会把"声明主机上的 https 443"变成"声明主机上的任意端口",形成webRequest看不见的双向信道;全量拒绝则没有这种边界漏洞,而且它不依赖任何授权状态,因此回收某个网络域名时无需重建 PAC。
2.4 导航遏制:白名单之外的另一道门
network.ts 的注释一针见血:"网络白名单在没有导航遏制时毫无意义——一个能把自身导航到任意页面的应用,等于直接离开了受管制的分区。"因此installNavigationPolicy(navigation.ts)做了两件事:
will-navigate:目标 URL 若非cherry-miniapp://<appId>/一律preventDefault();setWindowOpenHandler(() => ({ action: 'deny' })):弹窗直接拒绝,因为新窗口会落在该分区所有策略之外。
2.5 webview 门禁:最后一道主进程防线
宿主渲染进程运行在webSecurity: false下,理论上任何在宿主侧取得脚本执行权的代码都可能合成一个<webview nodeintegration preload="...">。applyMiniAppWebviewPolicy(webviewHost.ts)是唯一能否决这种挂载的主进程点:
- src 必须是
cherry-miniapp://<appId>/前缀; - 渲染进程传任何
preload/webpreferences/blinkfeatures都会被整体拒绝——因为 Electron 用无白名单的parseCommaSeparatedKeyValue解析webpreferences并最后展开覆盖,只有 6 个继承钳制(clamp)字段是安全的,而webviewTag不在其中,一旦放开 guest 就能挂出本门禁永远看不见的嵌套 webview; - 由主进程侧强制设定
nodeIntegration = false、contextIsolation = true、sandbox = true、webSecurity = true、webviewTag = false; - 分区命名统一收敛在 partition.ts:
persist:miniapp:<appId>,协议处理器只注册在这些专属分区上,宿主渲染进程因此根本无法寻址包文件。
三、被阻止的能力与官方替代方案(对照表)
以下是 sandbox.md 给出的完整对照表,左侧是你习惯写的浏览器 API,中间是它在沙箱中的实际行为,右侧是 Cherry Studio 提供的替代方案。逐行保留原文,不做任何删减:
| 你写的代码 | 会发生什么 | 应该改用 |
|---|---|---|
localStorage、sessionStorage | 抛出SecurityError——不透明源没有存储 | cherry.storage |
indexedDB.open(...) | 被拒绝(Reject)——原因同上 | blob 用cherry.file,状态用cherry.storage |
document.cookie、Cache API、caches.open | 无效操作 / 被拒绝 | cherry.storage |
fetch('https://api.example.com')、XMLHttpRequest、WebSocket、EventSource、navigator.sendBeacon | 被connect-src 'none'和宿主请求过滤器双重拦截——即使该主机已声明在manifest.network中 | cherry.network.fetch(仅 https、仅声明主机、请求/响应 ≤ 1 MB / 5 MB) |
<script src="https://cdn...">、<link href="https://...">、<img src="https://..."> | 被拦截 | 把资源打包进包内 |
new Worker(...)、SharedWorker、navigator.serviceWorker.register | 被拦截 | 在主线程运行,或内联进页面 |
<iframe>、<embed>、<object> | 被拦截(frame-src 'none'、object-src 'none') | 在页面内自行渲染 |
<webview> | 被主进程拒绝,且无论宿主窗口怎么开,你的页面webviewTag都是关闭的。这不是 CSP 管辖的事——Electron 的<webview>不属于frame-src治理的浏览上下文,它既不会携带本页的 CSP,也不会携带本页的请求过滤器 | 无 |
window.open、<a target="_blank"> | 被拒绝——不会创建任何弹窗 | 无。本版本没有"在浏览器中打开" |
<a download>、URL.createObjectURL(blob)加点击、导航到下载 | 被取消——不会弹出保存对话框 | cherry.file.export |
showOpenFilePicker、showSaveFilePicker、showDirectoryPicker | 被拒绝——File System Access 权限被拒 | 读取用<input type="file">,写入用cherry.file.export |
location.href = 'https://...'、<form action> | 导航到cherry-miniapp://<appId>/之外会被取消 | 只在包内导航 |
WebRTC(RTCPeerConnection) | UDP 被拦截,TURN/TCP 被路由到死代理——连接永远无法建立 | 无 |
Notification.requestPermission() | 恒为denied | cherry.notification.show |
navigator.clipboard.* | 被拒绝——剪贴板权限被拒 | cherry.clipboard(应用需持有键盘焦点) |
navigator.language、languagechange | 在加载时冻结,永不更新 | cherry.app.getInfo().locale与cherry.on('app.localeChange', ...) |
document.visibilityState、visibilitychange | 应用隐藏于 keep-alive 池期间永不变化 | cherry.on('app.visibilityChange', ...) |
beforeunload、pagehide、unload | 可能永远不会触发——应用可能被无通知地销毁 | 每次变更即保存;参见 Lifecycle |
理解这张表,关键是抓住三条主线:
- **存储类(存储、缓存、Cookie)**全部不可用,统一收敛到
cherry.storage/cherry.file两个宿主能力上; - 网络类统一收敛到
cherry.network.fetch,它由主进程发起请求,不属于浏览器上下文、不受 CORS 约束,但只接受https://且主机必须在manifest.network白名单内(见 manifest.md 的"Network hosts"一节); - **权限类(通知、剪贴板、文件选择器)**全部被拒,由宿主 API 替代,且这些替代 API 自身也有可见性、焦点等前置条件(
cherry.file.export要求 pane 可见,cherry.clipboard要求可见且聚焦,详见 capabilities.md)。
四、什么在沙箱里是允许的
限制之外,sandbox.md 明确列出以下可正常工作的能力,它们是 mini app 实现 UI 和本地逻辑的基础:
| 功能 | 说明 |
|---|---|
内联脚本、eval、new Function | script-src允许 |
| WebAssembly | .wasm以application/wasm提供;'unsafe-eval'覆盖编译 |
fetch('./assets/level.json') | 自身包可被 fetch——详见第五节 |
data:与blob:URL | 可用于运行时生成的图片、媒体和字体(URL.createObjectURL) |
| Canvas、WebGL、WebGPU、Web Audio | 标准浏览器特性,无网络依赖 |
history.pushState、hash 路由 | 包内同源导航允许 |
matchMedia('(prefers-color-scheme: dark)') | 跟随用户的 Cherry 主题,包括实时变化 |
<input type="file">、拖放文件到页面 | 你能拿到File对象——内容和文件名,永远不会是路径。和普通页面一样,dragover/drop需要preventDefault() |
| 在你的输入框内粘贴 | 按键本身可用;编程式读取剪贴板用cherry.clipboard.read |
五、fetch 自身包文件:跨源、404 语义与并发边界
由于文档源不透明,即使请求自己的包也算跨源请求。宿主为每一个包响应——包括 404 和 403——都附加Access-Control-Allow-Origin: *,由此带来两个关键行为:
fetch('./data.json')可以正常 resolve;- 缺失文件会 resolve 为一个
status === 404的Response,而不是抛出TypeError: Failed to fetch——所以请检查response.ok。
这些行为在 protocol.ts 的处理器实现中都能找到对应:
- 内容类型:根据扩展名映射(
.html、.js、.css、.json、.svg、.png、.jpg/.jpeg、.gif、.webp、.woff2、.wasm),其余一律application/octet-stream(CONTENT_TYPES); - 路径遏制:
realpath解析后必须落在包根目录内,不是字符串前缀比对——因为包内符号链接在朴素比对下可以绕过检查,任何逃逸路径返回 403(protocol.ts); - 并发读上限:每个应用 8 个活跃读、64 个排队读,第 73 个并发请求直接失败(
MAX_CONCURRENT_READS/MAX_QUEUED_READS,protocol.ts)。这是"权限不是速率"的典型——一个 guest 可以对自身大文件发出任意多的并发fetch,每个都占用主进程内存,因此必须限流。不要为大型资源并行发起上百个fetch调用; - 流式返回:文件以
readableWebStream流式发送而非readFile全量缓冲,防止 guest 在主进程内存里同时持有 N 份 100 MB 文件的副本; - 保留路径:
/__cherry/*预留给宿主资源——目前只有/__cherry/theme.css(见 Theming)。包内含顶级__cherry目录在安装时即被拒绝,且该前缀在处理器中先于磁盘解析,即使包内混入__cherry/目录也永远不会被服务出来(protocol.ts)。
六、多实例:状态共享与作用域边界
同一个应用可以同时在多个窗口运行(用户可以拆出标签页)。每个实例是独立的页面、拥有独立的 JavaScript 状态,但:
cherry.storage与cherry.file按应用共享——最后一次写入生效;cherry.ai的callId按实例隔离。
这意味着多窗口场景下没有"最后写入者协调"的保证,作者需要在业务层面处理竞态。另外从 lifecycle.md 可知,每个 pane 拥有独立的可见性与独立的隐藏期预算,同一应用拆出的窗口各自计量。
七、键盘:每个按键都属于你
当你的应用持有焦点时,Cherry 自身的快捷键——打印、保存、全局键绑定——都不会触发。这是因为宿主的按键中继 preload 不会为本地应用加载:MiniAppRuntimeService.ts 中bridgePreloadPath的注释说明,沙箱化的 preload 必须是单一打包文件,而能力桥接(capability bridge)已经占用了这个槽位,所以WebviewService会跳过 mini app 分区,键盘中继也就不会加载。
由此产生两条开发准则:
- 不要依赖宿主替你处理任何按键;
- 尽量别绑定用户期望 Cherry 处理的平台标准组合键——沙箱内每个按键都会原样交给你。
八、调试:把"看不见的拦截"变成可读信息
DevTools 可从宿主的 mini app UI 中打开(挂在 webview 上)。调试时注意两个信号:
- 被拦截的请求会在 Network 面板中显示为
(blocked:csp)或cancelled; - 任何包文件的响应头中都能读到那份完整的 CSP,用于核对当前策略。
结合 network.ts 的实现,onHeadersReceived会给每个非 devtools 响应追加 CSP,因此你在 DevTools 中看到的 CSP 与实际执行的一致;而shouldAllowRequest拒绝的请求会以cancelled形态出现(onBeforeRequest取消),CSP 拒绝的则以(blocked:csp)呈现——这两种失败形态分别对应三层遏制中的哪一层,可以直接用来定位问题。更深入的话,probes.md 记录了这些遏制层的全部实测依据(Electron 41.8.0 / Chrome 146),并给出每个探针的重建步骤,供维护者在 Electron 升级时复测。
九、小结:沙箱心智模型
Cherry Studio mini app 的沙箱可以概括为一句话:页面拿到一个"能跑 JS 的空浏览器壳",一切需要出壳的能力都通过主进程中的cherry.*桥接完成,并且每一道闸门都有对应的主进程强制实现。写作时请始终带着这张心智地图:
- 写状态:
cherry.storage(小 JSON 状态)/cherry.file(大 blob),别碰 Web Storage 与 IndexedDB; - 取网络:
cherry.network.fetch+ manifest 声明主机,别碰 fetch / XHR / WebSocket / 远端标签; - 出文件:
cherry.file.export,别碰下载属性与文件选择器; - 查环境:
cherry.app/matchMedia/ 宿主事件,别依赖navigator.language、Page Visibility 与卸载事件; - 任何资源:打包进包里,别指望 CDN 与 iframe。
把"Chrome 里能跑"的习惯放一边,以上述替代方案为准绳编写,你的 mini app 才能在这个受控环境中稳定、合规地运行。进一步阅读:Manifest 与权限声明、cherry.*能力清单与配额、生命周期与持久化规则、主题接入。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考