Electron 的进程间通信(IPC)是渲染进程与主进程之间唯一的管道。一旦这条管道被恶意利用,攻击者就可能从界面层侵入系统层,执行任意文件操作甚至命令。因此,IPC 的安全性不能建立在“渲染进程是可信的”这种假设之上,而必须对每一条消息进行严格的验证和过滤。
本节从三个角度展开:参数校验、白名单校验、以及拒绝在主进程执行动态代码。这三个措施组合起来,能有效阻断绝大多数基于 IPC 的攻击路径。
25.2.1 参数校验:永远不要信任渲染进程的数据
渲染进程随时可能受到 XSS 攻击或第三方脚本的污染,因此主进程接收到任何消息时,第一件事就是校验参数的类型、格式和范围。
原则:
- 对入参做类型检查,使用
typeof、Array.isArray、instanceof等。 - 对路径参数做严格限制,防止目录穿越(
../、绝对路径等)。 - 对命令参数使用白名单或正则匹配,避免注入。
- 尽量使用 JSON Schema 或运行时校验库(如
zod、joi)统一管理。
真实案例:假设你有一个 IPC 通道允许渲染进程请求读取某个文件的内容。
❌ 不安全的实现:
// 主进程
ipcMain.handle('read-file', async (event, filePath) => {
const content = await fs.readFile(filePath, 'utf-8');
return content;
});
攻击者如果控制渲染进程,可以传入 /etc/passwd 或 ~/.ssh/id_rsa,直接窃取敏感文件。
✅ 安全的实现:
const path = require('path');
const { app } = require('electron');
// 只允许读取应用用户数据目录下的文件
const allowedDir = app.getPath('userData');
ipcMain.handle('read-file', async (event, filePath) => {
// 1. 类型校验
if (typeof filePath !== 'string' || filePath.length === 0) {
throw new Error('Invalid file path');
}
// 2. 规范化路径并解析符号链接,然后检查是否在允许目录内
const resolvedPath = path.resolve(allowedDir, filePath);
if (!resolvedPath.startsWith(allowedDir)) {
throw new Error('Path traversal detected');
}
// 3. 可选:进一步检查文件是否存在以及是否为普通文件
const stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
throw new Error('Not a regular file');
}
return await fs.readFile(resolvedPath, 'utf-8');
});
这种“先解析再校验边界”的模式,是文件操作类 IPC 必须遵循的守则。
25.2.2 白名单校验:按功能精确放行 IPC 通道
Electron 的 IPC 基于字符串通道名,如果渲染进程可以随意调用任何通道,一旦某个通道存在漏洞,整个主进程就暴露了。白名单校验是指在主进程侧明确列出允许接收的通道名,并对每个通道单独绑定处理器,忽略未定义的通道。
做法:
- 使用一个集中的路由器对象管理所有 IPC 处理器。
- 拒绝一切未知通道名的请求。
- 结合
preload暴露有限的方法,形成“双重白名单”。
例如,在 preload 中只暴露特定方法:
// preload.js
contextBridge.exposeInMainWorld('api', {
readFile: (relativePath) => ipcRenderer.invoke('read-file', relativePath),
// 不再暴露通用的 send 或 invoke,避免渲染进程随意构造通道
});
在主进程中,可以这样组织:
// main.js
const ipcMethods = {
'read-file': async (event, filePath) => { /* 校验后读取 */ },
'save-file': async (event, data) => { /* 校验后保存 */ },
};
ipcMain.handle('*', (event, channel, ...args) => {
// 这并非有效语法,仅为示意;实际需要显式注册每个通道
});
// 实际实现:逐个注册
if (ipcMethods['read-file']) {
ipcMain.handle('read-file', wrapHandler(ipcMethods['read-file']));
} else {
// 通道不存在就记录警告,不做任何事
}
更严格的方案是使用一个注册表,并将未知通道调用视为攻击行为,直接上报并阻断,防止暴力探测。
25.2.3 拒绝在主进程执行动态代码
Electron 的 Node.js 环境允许使用 eval()、new Function() 或 child_process.exec() 等方式执行任意代码。如果这些能力以任何形式暴露给渲染进程,攻击者就能绕过所有参数校验,直接执行系统命令。
威胁模型:
- 渲染进程通过 IPC 传递给主进程一段需要“解析”的字符串,主进程用
JSON.parse或eval处理。 - 主进程根据渲染进程的参数动态拼接命令字符串,再调用
exec。
解决办法:
- 永远不要使用
eval或new Function处理来自渲染进程的数据,即便是间接方式也不行。 - 对于命令行调用,永远不要拼接字符串,而是使用
child_process.spawn并传递参数数组,从根本上避免命令注入。 - 限制
child_process.exec的使用,必须使用时,命令本身硬编码,只允许参数经过严格白名单过滤。 - 在
webPreferences中禁用nodeIntegration,启用contextIsolation,并考虑使用sandbox进一步限制渲染进程。
真实的反面案例:
假设你提供给用户一个“自定义计算”功能,渲染进程发送公式,主进程计算:
ipcMain.handle('calc', (event, expression) => {
// 危险!攻击者可传入 `require('fs').readdirSync('/')` 等
return eval(expression);
});
这等同于开门揖盗。正确做法是使用安全的表达式解析器(如 math.js 或 new Function 沙箱化),并严格限制可用的全局变量和模块。
针对 child_process 的安全策略:
要用 spawn 代替 exec,因为 spawn 不接受 shell 命令字符串,参数与命令分离,天然防注入:
// 不安全
exec(`ls -la ${userInput}`); // 注入风险
// 安全
spawn('ls', ['-la', userInput]); // userInput 被当作一个参数,不会解析为多条命令
如果确实需要调用复杂命令,请将允许的命令和参数白名单化,并在运行时逐项匹配。
25.2.4 综合实践:一个安全的 IPC 架构模板
很多实际项目会将上述措施整合成一个健壮的 IPC 骨架:
- 定义通道与参数契约(TypeScript 中可以定义接口)。
- 在 preload 中暴露精确的函数,每个函数只调用一个通道。
- 主进程注册处理器,每个处理器开头调用统一的校验函数,该函数根据通道名从配置中读取参数约束模型(如 Zod schema)进行验证。
- 校验失败直接抛错并记录日志,不要返回详细信息给渲染进程,避免信息泄露。
- 彻底杜绝
eval等动态执行,并审计所有child_process调用。
这样的分层防御,即便渲染进程被攻破,攻击者能做的也只是调用那些预先定义好的、有严格边界限制的功能,而无法肆意操控用户系统。
进程通信安全不是一次性的配置,而是贯穿整个 Electron 应用生命周期的持续实践。当你新增任何一个 IPC 接口时,都应当像审查 API 接口一样,追问:“如果调用方是恶意的,我能想到的最坏情况是什么?” 然后用本节的三板斧把那个漏洞堵上。