如果你直接从渲染进程调用 Node.js 或 Electron 的原生 API,就像给网页开了后门——任何一个 XSS 漏洞都可能让攻击者读取用户硬盘上的任意文件。预加载脚本的存在,就是为了在这条危险的捷径上建一道安检关卡:它运行在一个介于纯网页环境和完整 Node.js 环境之间的特权沙箱中,可以有选择地向渲染进程暴露有限且安全的能力。
为什么需要预加载脚本
在早期的 Electron 版本中,渲染进程可以直接开启 nodeIntegration: true,这意味着你的 index.html 里的 <script> 标签内就能 require('fs') 并读写本地文件。这样做开发起来确实方便,但也极其危险:
- 任何第三方脚本或被注入的恶意代码都能直接控制用户的文件系统;
- 你无法控制哪些 API 会被调用、以什么参数调用;
- 一旦出现安全漏洞,攻击面是整个 Node.js 运行时。
现代 Electron(v12 起默认启用 contextIsolation: true 并禁用 nodeIntegration)的做法是把这道门完全关上,然后只开一扇小窗——这扇窗就是预加载脚本。
预加载脚本的工作方式
预加载脚本是一个独立的 JavaScript 文件,在网页内容加载之前运行。它的运行环境很特殊:
- 它拥有完整的 Node.js 和 Electron API 访问权限(可以
require('fs')、require('electron')); - 但它与网页内容运行在不同的 JavaScript 上下文中,两者并不直接共享全局变量;
- 它可以使用
contextBridge将特定的功能安全地暴露给网页。
具体流程是:
- 主进程创建窗口时,通过
webPreferences.preload指定预加载脚本的路径。 - 窗口加载网页内容之前,Electron 先执行预加载脚本。
- 预加载脚本使用
contextBridge.exposeInMainWorld向渲染进程的全局作用域注入一个自定义 API 对象。 - 网页端通过这个注入的对象调用被暴露的方法,预加载脚本内部再通过
ipcRenderer向主进程转发请求。 - 主进程接收到请求后,在权限受控的环境中完成实际操作(如读文件、显示系统对话框),并将结果返回。
这样一来,网页完全接触不到 fs 或 child_process 这些危险模块,只能调用你预先定义好的几个函数。攻击者即使完全控制了渲染进程,也只能得到你主动暴露的那几个方法,而无法为所欲为。
能力受控暴露:只给需要的,不给全部的
“受控暴露”的核心思想是最小权限原则:只让渲染进程调用那些它完成业务逻辑所必须的方法,而且对参数和返回结果进行严格的验证与过滤。
一个典型的预加载脚本长这样:
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// 暴露一个安全的“打开文件”功能
openFile: () => ipcRenderer.invoke('dialog:openFile'),
// 暴露一个“保存文件”功能,但调用方不能决定保存路径
saveContent: (content) => ipcRenderer.invoke('file:save', content),
// 暴露一个只读的系统信息获取方法
getAppVersion: () => ipcRenderer.invoke('app:getVersion'),
// 单向事件监听:主进程有通知时触发回调
onNotification: (callback) => {
ipcRenderer.on('notification', (event, message) => callback(message));
}
});
在渲染进程(你的 Vue/React 组件)中,你就可以通过 window.electronAPI.openFile() 来发起文件选择,完全不需要知道底层是 dialog.showOpenDialog,更接触不到 fs 模块。
如果你想进一步限制,还可以在预加载脚本里对参数做校验:
contextBridge.exposeInMainWorld('safeAPI', {
deleteFile: (filePath) => {
// 禁止删除系统关键目录
if (typeof filePath !== 'string' || filePath.includes('/System/')) {
throw new Error('不允许的操作');
}
return ipcRenderer.invoke('file:delete', filePath);
}
});
这样即便渲染进程代码被人篡改,也无法随意删除文件。
真实开发中的最佳实践
- 永远不要重新启用
nodeIntegration。除非你在开发一个完全不联网、完全可信的内部工具,否则出于安全考虑,也应保持默认的contextIsolation: true和nodeIntegration: false。
- 预加载脚本只做桥接,不处理业务逻辑。它应该很薄,主要工作就是参数校验和 IPC 封装。真正的业务逻辑放在主进程或渲染进程自己的层里。
- 避免直接将 Electron 的复杂对象暴露出去。比如不要把整个
ipcRenderer对象或process对象直接挂在window上,否则等于没做限制。只暴露用得到的、精确命名的函数。
- 对从主进程传回的数据保持警惕。哪怕数据源头是你自己的主进程,也应将它视为可能被篡改的数据(如果主进程代码存在漏洞)。在渲染进程侧对返回值做基本的类型和格式检查是个好习惯。
- 在开发与生产中使用同一策略。不要在开发时为了方便而打开
nodeIntegration,等到打包前再关掉——这样你会发现很多功能在关闭后没法工作,而且安全习惯也培养不起来。
通过预加载脚本实现的能力桥梁,你既保留了 Electron 赋予桌面应用的强大功能,又把安全风险锁定在可控的范围内。它是现代 Electron 应用架构中不可缺失的一环,也是从“能跑起来”到“能安全上生产”的关键一步。