4、预加载脚本(Preload Script):正确使用预加载脚本、暴露安全的API、避免直接暴露Node.js模块

预加载脚本,说白了就是Electron里连接「浏览器世界」和「Node.js世界」的一座桥。这座桥如果设计得不好,攻击者就能顺着它从渲染进程直接摸到你的系统底层。我见过太多项目因为这一步没做好,整个应用的安全防线形同虚设。

4.1 预加载脚本的核心作用

Electron的渲染进程默认跑在沙箱里,它不能直接调用Node.js的API。但很多时候我们又需要让页面能做一些「超越浏览器」的事情,比如读写文件、调用系统对话框。这时候就需要预加载脚本登场了。

预加载脚本在渲染进程创建时执行,但它拥有Node.js的完整权限。你可以用它来暴露一些安全的接口给页面,而不是直接把整个Node.js模块丢过去。

核心原则:渲染进程不该知道它运行在Electron里。它只应该通过你精心设计的API来完成任务。

4.2 正确暴露API的方式

我个人习惯用 contextBridge 来暴露API。这是Electron官方推荐的做法,它能确保暴露的接口是安全的、可控的。

来看一个正确的例子:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  // 暴露一个安全的文件读取接口
  readFile: (filePath) => {
    // 在这里做路径校验,防止任意文件读取
    const allowedPaths = ['/data/', '/config/']
    const isAllowed = allowedPaths.some(path => filePath.startsWith(path))
    if (!isAllowed) {
      return Promise.reject(new Error('路径不允许'))
    }
    return ipcRenderer.invoke('read-file', filePath)
  },
  
  // 暴露一个获取系统信息的接口
  getSystemInfo: () => {
    return ipcRenderer.invoke('get-system-info')
  }
})

这样,页面里只能通过 window.electronAPI.readFile() 来读取文件,而且路径还被我们限制死了。攻击者就算拿到了这个接口,也搞不出什么大动静。

4.3 绝对不要做的事

嗯,这里要注意。有些开发者图省事,直接在预加载脚本里这么写:

// ❌ 错误示范:直接暴露Node.js模块
window.fs = require('fs')
window.child_process = require('child_process')

我曾经在一个客户的项目里看到过这种写法。当时他们的应用被XSS攻击了,攻击者通过 window.fs.writeFileSync() 直接往系统目录写入了恶意脚本。那次事故导致整个内网环境被渗透,教训极其惨痛。

警告:永远不要直接把 require、process、Buffer 等Node.js全局对象暴露给渲染进程。这等于把家里的钥匙直接交给了小偷。

4.4 安全的API设计原则

设计安全的API,我总结了几个要点:

原则 说明 示例
最小权限 只暴露必要的功能,不要给「万能接口」 暴露 saveFile 而不是 fs.writeFile
参数校验 所有传入的参数都要做类型和范围检查 检查文件路径是否在允许的目录内
返回值过滤 不要返回原始对象,只返回需要的字段 返回 { name, version } 而不是整个 process
错误处理 不要暴露内部错误信息给渲染进程 返回 '操作失败' 而不是 'Error: EACCES'

4.5 使用ipcRenderer进行安全通信

预加载脚本最常见的用途就是作为渲染进程和主进程之间的通信桥梁。但这里也有坑。

你想想看,如果直接把 ipcRenderer 整个暴露出去,攻击者就能监听所有IPC通道,甚至伪造消息。所以我们要做的是:

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
  // 只暴露特定的事件监听
  onUpdateAvailable: (callback) => {
    // 使用一个固定的通道名,不让渲染进程自定义
    ipcRenderer.on('update-available', (_event, data) => {
      callback(data)
    })
  },
  
  // 只暴露特定的发送方法
  requestUpdate: () => {
    ipcRenderer.send('request-update')
  }
})

这样做的好处是,渲染进程只能通过我们定义好的通道来通信。它不能随便监听 ipcRenderer.on('*'),也不能发送任意消息到主进程。

4.6 避坑指南

我曾经踩过一个坑:在预加载脚本里使用了 remote 模块。那时候Electron还没废弃 remote,我图方便直接用了。结果发现渲染进程可以通过 remote.require 加载任意模块,安全漏洞一抓一大把。

后来我彻底戒掉了 remote,改用 ipcRenderer + contextBridge 的组合。虽然代码量多了点,但安全性提升了好几个档次。

小技巧:在预加载脚本里,可以用 process.contextIsolated 来检查上下文隔离是否开启。如果返回 false,说明你的安全配置有问题,赶紧去检查 webPreferences 的设置。

4.7 总结

预加载脚本的正确使用,说白了就是一句话:只给渲染进程它需要的,不给它想要的。你想想看,一个页面本来只需要显示个文件列表,你给它整个 fs 模块干嘛?

我建议每个Electron开发者都把预加载脚本当成「外交官」来设计——它只传递必要的信息,而且每句话都要经过严格审查。这样你的应用才能经得起安全考验。