【Electron】安全模型:把渲染进程当成不可信客户端
上一篇把 IPC 拆到了"三个世界"的粒度:主进程、preload 的隔离世界、网页的主世界,以及它们之间怎么通过 contextBridge 和 IPC 传递消息。但这套体系有个隐含前提——渲染进程是不可信的。这篇文章就从安全的角度重新思考这套架构:默认值防什么、配置组合有什么陷阱、IPC 作为信任边界怎么校验。
渲染进程不可信是贯穿原则
渲染进程加载的是网页内容。网页可能被 XSS 注入、可能嵌了第三方 iframe、可能从外部加载脚本。Electron 不是浏览器,它的 JS 能通过 IPC 调主进程读写文件、弹系统对话框。浏览器里的 XSS 最多窃取 cookie 或修改页面内容,Electron 渲染进程被攻破后,攻击者可以调主进程读写用户磁盘文件。
因此官方 Security 文档始终围绕一个核心假设:只有主进程是可信的。主进程执行的代码是你打包进去的,不会从外部注入。渲染进程默认不可信,它发来的每条消息都要当成不可信客户端的请求来对待。
基于这个假设,Electron 的防御体系由三层构成:安全默认值把渲染进程限制在最小权限范围内,contextBridge 控制暴露的接口,IPC handler 校验每条消息的合法性。
三组安全默认值
先看三个经常并排出现的配置项。
nodeIntegration(Electron 5 起默认 false)。决定渲染进程能不能直接用 require('fs') 读写文件。如果 UI 逻辑里需要调用系统 API,标准做法是封装成 IPC 调用,而不是在渲染进程里直接 require。早期 Electron 应用常开着 nodeIntegration 开发,认为方便,但网页引入了第三方脚本后就等于开了后门。
contextIsolation(Electron 12 起默认 true)。它的作用是阻止网页代码直接访问 preload 脚本的变量,如第一篇介绍的,preload 跑在一个独立的 V8 上下文里,网页从 window 访问不到 preload 那边的东西。
sandbox(Electron 20 起默认 true)。这是操作系统级别的限制,不止限制 JS 层面的访问。开启沙箱后,渲染进程无法写入磁盘、无法开新窗口,preload 能用的 Node API 也被压缩到极小集合,只剩 contextBridge、ipcRenderer、crashReporter、nativeImage、webFrame、webUtils,再加上 events、timers、url 这三个 Node 内置模块。连 require 的方式都变了:沙箱下 preload 脚本不能拆分文件,必须是一个独立的文件,所有依赖通过打包工具(webpack、esbuild 等)提前捆进去。
这几个默认值经历了逐版本收紧的演进:
| 版本 | 变化 |
|---|---|
| Electron 5 | nodeIntegration 默认 false(2019 年) |
| Electron 12 | contextIsolation 默认 true(2021 年) |
| Electron 14 | remote 模块从内置移除,改用 @electron/remote |
| Electron 20 | sandbox 默认 true(2022 年) |
| Electron 29 | ipcRenderer 不再能通过 contextBridge 传递 |
| Electron 40 | 直接通过渲染进程访问 clipboard API 被废弃 |
这些变化不是凭空发生的,每个版本收紧背后都有真实漏洞报告推动。共同指向一个持续的趋势:把渲染进程的权限一步步关小。
配置组合的陷阱:关一个等于关一双
这三个配置之间不是独立的。
contextIsolation 和 sandbox 之间有一层强制关联。禁用了 contextIsolation,也会连带禁用 sandbox。原因是:如果把 contextIsolation 设为 false,渲染进程和 preload 共享同一个 V8 上下文,网页代码能直接访问 preload 的 window,那 sandbox 的隔离也就失去了意义。攻击者不需要绕过沙箱,因为 preload 的 API 直接暴露在网页代码的视野里。所以 Electron 框架层面做了强制关联:关 contextIsolation,sandbox 自动失效。
反过来,如果生产应用需要关掉 sandbox(因为要使用某些 Node API),contextIsolation 务必保持为 true。这是两个独立开关,关 sandbox 不会连坐关掉 contextIsolation,但关掉它等于放弃了 context isolation 带来的 JS 层防护。
还有一个常见误解是 sandbox 和 nodeIntegration 的关系。sandbox 开启时会强制覆盖 nodeIntegration 为 false。即使你在配置里写了 nodeIntegration: true,只要 sandbox 是 true,渲染进程仍然拿不到 Node.js。反过来,把 nodeIntegration 设为 true 也会隐式禁用沙箱,因为沙箱根本不允许 require 加载 C++ 模块。
| 配置项 | 默认值(自哪个版本起) | 关联影响 |
|---|---|---|
contextIsolation | true(v12) | 设为 false 会连带关掉 sandbox |
sandbox | true(v20) | 开启时强制覆盖 nodeIntegration: false |
nodeIntegration | false(v5) | 设为 true 会隐式禁用沙箱 |
contextBridge 只是第一道门
contextBridge 只决定了暴露哪些接口、不暴露哪些接口,但不决定谁在调用它们。为什么 contextBridge 不能校验调用者?因为它是 JS 层的 API,同一 V8 上下文里的所有 JS 代码——包括 XSS 注入的脚本——都能调用 window.electronAPI 上暴露的所有方法。JS 没有"调用链身份"的概念,contextBridge 也无从分辨调用来自开发者写的界面代码还是侵入脚本。
前一篇讲过,不能把整个 ipcRenderer 暴露出去,也不能把带 event 对象的回调原样传过去。这些做得再好,也只是给攻击者设了一道"只能走这些接口"的限制,不是"根本不能调用"的限制。真正的防线在后端。
IPC 是需要校验的信任边界
因为渲染进程不可信,每条从渲染进程发来的 IPC 消息都处在这条信任边界上。主进程不能默认消息来自自己的界面,必须对每条消息做校验。
校验内容
最基本的做法是校验参数。如果 IPC handler 只接受特定格式的数据,就应该在 handler 里做参数验证:
ipcMain.handle("save-file", async (event, { filename, content }) => {
if (typeof filename !== "string" || typeof content !== "string") {
throw new Error("invalid arguments");
}
// 防止路径穿越(注:真实场景中手写 includes("..") 不足以防御编码绕过;
// 生产代码应使用 path.resolve() 配合白名单前缀进行校验)
if (filename.includes("..")) {
throw new Error("invalid path");
}
await fs.writeFile(filename, content, "utf-8");
});
校验 sender
校验内容还不够。渲染进程可能嵌了 iframe,iframe 里的代码同样能通过 contextBridge 暴露的接口给主进程发消息。所以还需要验证消息的来源。
ipcMain.handle 注册的 handler 收到的第一个参数是 IpcMainInvokeEvent,上面有一个 senderFrame 属性,记录了这条消息是从哪个 frame 发来的。可以用它来校验来源:
ipcMain.handle("get-user-data", (event) => {
const senderOrigin = new URL(event.senderFrame.url).origin;
if (senderOrigin !== "https://myapp.com") return null;
return userDataService.get();
});
校验时建议把协议、域名、端口一并检查。只检查域名的话,http://evil-myapp.com 就可能绕过。另外,加载本地文件的 Electron 应用(file:// 或自定义协议)需要特别注意:这里检查的是 https:// origin,如果你的应用加载的是本地页面,event.senderFrame.url 的 origin 不是 https://,这段代码会拒绝所有合法请求。这种情况下可以改用 frame URL 的路径白名单:
ipcMain.handle("local-data", (event) => {
const senderUrl = event.senderFrame.url;
// 只允许 app 自身打包的页面发起的 IPC 调用
if (!senderUrl.startsWith("file://") && !senderUrl.startsWith("myapp://")) {
return null;
}
return localDataService.get();
});
iframe 嵌套的情况下,多个 iframe 可能层层嵌套。event.senderFrame 只记录最终发出消息的那个 frame(最内层 iframe)。如果需要确认整条 frame 链都在可信范围内,可以通过 senderFrame.parent 逐层向上遍历:
function isFrameChainTrusted(frame) {
let f = frame;
while (f) {
try {
const origin = new URL(f.url).origin;
if (origin !== "https://myapp.com") return false;
} catch {
return false;
}
f = f.parent;
}
return true;
}
Content Security Policy:挡住 XSS 的第二道防线
IPC handler 的校验是后端防线,但更理想的做法是在前端就把 XSS 挡住。CSP(Content Security Policy)可以在浏览器层面告诉它:哪些来源的脚本可以执行、哪些来源的资源可以加载。即便开发者不小心在代码里插入了 innerHTML 操作导致注入发生,CSP 也能阻止恶意脚本实际执行。
在 Electron 里设置 CSP 有两种方式。
一种是在主进程的 session 层拦截 HTTP 响应头全局注入:
const { session } = require("electron");
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self'; script-src 'self'",
],
},
});
});
另一种是在 HTML 页面中设置 <meta> 标签:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
前者的好处是全局生效,不会被页面代码覆盖;后者的好处是配置在页面里,不需要动主进程代码。实践中可以组合使用:全局 CSP 设一个严格的基线,页面里按需补充允许的外部来源。
需要注意的是,meta 标签形式的 CSP 存在一个盲区:如果页面已经被 XSS 注入,攻击者可以篡改或删除 <meta> 标签来绕过 CSP 限制。这也是为什么 CSP 被称为"第二道"防线而非"唯一"防线——它依赖 DOM 完整性,而 DOM 可能已经被攻破。
Bishop Fox 在它们的 Electron 安全框架文章里有一句贴切的比喻:"CSP is a seatbelt"。它不能防止事故,但发生事故时能大幅降低伤害。CSP 的设计目的是阻挡 XSS 利用,不是防止 XSS 本身。它的局限性也很明显:'self' 在 Electron 的 file:// 协议下含义模糊。Electron 默认通过 file:// 协议加载打包在本地的 HTML 文件,此时 CSP 中 'self' 指向文件系统。攻击者只需要往本地写入一个文件就能绕过限制。这也是为什么官方安全建议第 18 条推荐用自定义协议(如 app://)替代 file://。
safeStorage 与凭据存储
以上讨论的都是运行时防御——如何防止渲染进程发出的恶意请求。桌面应用还有一个不直接相关但在实践中同样重要的安全维度:敏感信息的本地存储。
访问令牌、API Key 等凭据用 localStorage 保存虽然方便,但数据以明文形式存在磁盘上,同一台机器的其他进程可以读取。如果渲染进程被攻破,攻击者可以直接从 localStorage 窃取这些凭据。Electron 提供了 safeStorage API,用操作系统自带的加密机制来保护磁盘上的数据。
不过需要注意,safeStorage 解决的是"磁盘上的数据安全",不是"运行时被攻破的渲染进程"——攻击者依然可以通过暴露的 safeStorage API 解密数据:
const { safeStorage } = require("electron");
// 加密
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString("my-secret-token");
fs.writeFileSync("token.enc", encrypted);
}
// 解密
const encrypted = fs.readFileSync("token.enc");
const decrypted = safeStorage.decryptString(encrypted);
跨平台行为上,macOS 用钥匙串,Windows 用 DPAPI(同一用户登录凭据才能解密),Linux 用 kwallet 或 gnome-libsecret(具体取决于桌面环境)。Linux 上如果没有密码管理器可用,会回退到硬编码明文密码,此时数据实际上不受保护。多数主流 Linux 桌面发行版默认预装了密码管理器,因此 isEncryptionAvailable() 通常返回 true,但无头服务器环境需特别注意。
Electron 24 开始还提供了异步版本 encryptStringAsync / decryptStringAsync,返回值带一个 shouldReEncrypt 字段,用于指示是否需要重新加密——当操作系统的加密密钥发生轮换时(比如用户修改了登录密码,或 Linux 的密钥存储更新了加密密钥),异步 API 会标记应当使用新密钥重新加密存储的数据。
几个明显的反模式
把上面说的三层防线反过来看,就容易理解为什么有些做法是反模式了。
对应 contextBridge 层的反模式:把 ipcRenderer 整个暴露给网页。 contextBridge 在跨上下文传递 ipcRenderer 时会把它的内部 C++ 绑定剥离(因为结构化克隆无法处理原生绑定对象),所以 Electron 29 之前 window.electronAPI.send 会变成一个不工作的空壳,29 之后直接报错。正确做法是针对每个 IPC 消息暴露一个独立的方法。
对应安全默认值层的反模式:开 nodeIntegration: true 加载远程内容。 如果应用需要加载远程网页(比如内置浏览器),开了 nodeIntegration 等于把系统权限交给远程网站,网站里的任意脚本都能读写用户的文件。
对应安全默认值层的另一条:把 shell.openExternal 的参数交给用户控制。 shell.openExternal 可以启动系统关联程序打开 URL,参数完全来自用户(比如聊天消息里的链接)会导致攻击者用 file:// 协议启动本地程序。
对应 IPC 校验层的反模式:不验证 IPC 来源就直接返回数据。 如前文所述,iframe 里的代码也能通过 contextBridge 暴露的接口发 IPC 消息。主进程需要确认消息来自你信任的页面。
生产项目的常见取舍
生产 Electron 应用有时会选择关闭某个默认安全开关,这不是因为它们不安全,而是因为渲染进程确实需要某些特权。但这里有一个需要分辨清楚的问题:即使加载的是自己打包的本地页面,渲染进程在技术上仍然是不可信的——本地页面被 XSS 注入的可能性比远程内容小得多,但并非零。攻击面的大小决定了风险的等级,不能因此就认为渲染进程"可信"了。
一个典型的场景是某些应用为了使用系统原生能力,在 webPreferences 里设置 sandbox: false。关闭沙箱后 preload 可以加载更多 Node 模块,代价是渲染进程的攻击面变大了。取舍的关键在于:这个应用加载的内容都是自己打包的本地文件,还是包含用户控制的远程内容?如果是前者,sandbox: false 的做法是相对可控的——但前提是保持 contextIsolation: true,并配合 CSP、IPC 校验等其他防线;如果是后者,保持沙箱开启是底线。
这类取舍决策应该显式记录下来。IPC handler 的校验、CSP 策略、shell.openExternal 的白名单——这些代码上的安全措施不能因为关了某个开关就被绕过去。
加固参考
除了框架自带的安全机制,社区也有一些现成的加固实践可以参考。
Sindre Sorhus 维护了 electron-secure-defaults 和 electron-hardener 两个包(后来被 1Password 采用和扩展),前者覆盖了 BrowserWindow 的默认安全配置,后者则进一步通过禁用 Node CLI 参数等方式压缩攻击面。
Bishop Fox 的安全框架文章提供了一份推荐配置清单,涵盖 webPreferences 的每个选项,并对 CSP、协议处理器、文件操作防护等做了讨论。其中一条建议很有启发性:IPC 方法应该用命名空间前缀做隔离,这样即使 contextBridge 暴露的接口出问题,攻击者也只能接触到有限的方法集合。实现起来很简单:
// 主进程:只处理以 "fs_" 开头的 IPC 调用
ipcMain.handle(/^fs_/, async (event, method, ...args) => {
const handlers = {
fs_read: () => fs.readFile(...args, "utf-8"),
fs_write: () => fs.writeFile(...args),
};
if (!handlers[method]) throw new Error("unknown method");
return handlers[method]();
});
参考资料
- Security(Electron 官方文档)
- Context Isolation(Electron 官方文档)
- Process Sandboxing(Electron 官方文档)
- safeStorage API(Electron 官方文档)
- Breaking Changes(Electron 官方文档)
- Breach to Barrier: Strengthening Apps with the Sandbox(Electron 官方博客)
- electron-secure-defaults(GitHub)
- electron-hardener(GitHub)
- Design A Reasonably Secure Electron Framework(Bishop Fox)