第五章:网络请求安全
前几章解决的是页面和进程之间的安全边界:
- Renderer 加载什么内容
- Renderer 拥有什么能力
- Node.js 能不能进入 Renderer
- IPC 可以调用哪些功能
这一章继续将 Electron 与外部的连接:网络请求。
网络请求是 Electron 中一个很容易被低估的攻击面。
在 Renderer 中,网络请求运行在 Chromium 的网络模型里;到了 Main,情况就复杂了。Main 既可以使用 Node.js 自己的网络栈,也可以使用 Electron 提供的 Chromium 网络能力。
所以同样是:
js
fetch(url)
放在不同的位置,安全边界并不一样。
5.1 一个应用,多种网络出口
Electron 应用里最常见的两类网络出口是 Renderer 和 Main。

Renderer 中:
js
fetch('https://api.example.com/data')
请求经过 Chromium。
Main 中则可能是:
js
fetch('https://api.example.com/data')
这里使用的是 Node 的网络能力。
也可能是:
js
net.fetch('https://api.example.com/data')
这里又回到了 Electron 的 Chromium 网络栈。
所以 Main 进程内部其实也存在两种不同的网络路径。
可以简单理解为:
- Renderer 的请求走 Chromium Network;
- Main 的
Node fetch / http / https走 Node 网络栈; - Main 的
net.fetch / net.request走 Chromium Network,受 Session 与代理约束。
这一区别会影响后面的代理、证书、Session 等行为。
更重要的是,Renderer 和 Main 对 URL 的信任边界完全不同。
例如:
js
// Renderer
fetch('https://api.example.com/data')
这是页面自己发起的请求。
而:
js
// Main
ipcMain.handle('url:fetch', async (_, url) => {
const response = await fetch(url)
return {
status: response.status,
body: await response.text()
}
})
则变成了:
Renderer → IPC → Main → Node fetch → 任意 URL。
第二种结构让 Main 替 Renderer 决定网络目标。
如果 url 可以被攻击者控制,问题就来了。
5.2 SSRF的产生
SSRF(Server-Side Request Forgery)通常出现在 Web 服务中。
例如服务器提供:
text
GET /fetch?url=https://example.com
攻击者把 URL 换成:
text
http://127.0.0.1:6379/
服务器就替攻击者访问了本机 Redis。
Electron 中同样存在这个问题,只是服务器变成了 Main Process。
一个典型的危险实现:
js
// 典型危险实现
ipcMain.handle('url:fetch', async (_, url) => {
const response = await fetch(url)
return {
status: response.status,
body: await response.text()
}
})
Renderer 可能原本只需要访问:
text
https://cdn.example.com/file.zip
攻击者却可以让 Main 请求:
text
http://127.0.0.1:6379/
或者:
text
http://127.0.0.1:8080/
甚至:
text
http://127.0.0.1:9222/
这些端口可能对应本机运行的 Redis、管理后台、DevTools 或其他本地服务。
云环境下还存在:
text
http://169.254.169.254/latest/meta-data/
这类地址常用于云实例元数据服务。AWS 等云环境已经通过 IMDSv2 等机制降低了简单 SSRF 获取实例凭证的风险,但应用本身不能依赖云平台的默认配置。
攻击路径可以表示成:

浏览器页面受到同源策略、CORS 等限制时,通常无法随意完成这样的访问。
Main 进程没有这些浏览器层面的限制。
所以:
一旦 Main 提供了"替页面访问任意 URL"的能力,SSRF 就进入了 Electron 的攻击面。
5.3 SSRF防护
SSRF 防护有两种思路。
第一种是:
允许访问任意公网地址 + 拦截内网地址。
第二种是:
只允许访问明确的业务地址。
对于大多数 Electron 应用,第二种更简单。
例如应用只需要:
text
api.example.com
cdn.example.com
download.example.com
可以直接:
js
const ALLOWED_HOSTS = new Set([
'api.example.com',
'cdn.example.com',
'download.example.com'
])
function validateUrl(input) {
const url = new URL(input)
if (url.protocol !== 'https:') {
throw new Error('unsupported protocol')
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new Error('host not allowed')
}
if (url.port && url.port !== '443') {
throw new Error('port not allowed')
}
return url
}
这比维护一张不断增长的私网黑名单更直接。
如果业务确实需要访问任意公网 URL,再进入下一层检查:
一次请求依次经过 Scheme → Host → DNS → IP → Port → Redirect,每一环都可能成为绕过点。
5.3.1 防护1 - Scheme校验
业务只需要 HTTPS 时:
js
if (url.protocol !== 'https:') {
throw new Error('unsupported protocol')
}
这样:
text
http:
file:
data:
javascript:
等其他协议不会进入后续网络请求流程。
URL 校验最好在进入网络库之前完成。
5.3.2 防护2 - Host校验
固定业务域名时:
js
const ALLOWED_HOSTS = new Set([
'api.example.com',
'cdn.example.com'
])
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new Error('host not allowed')
}
这里使用完整 Host 匹配。
例如:
text
api.example.com
允许。
text
api.example.com.evil.com
不会因为字符串包含 example.com 而通过。
如果业务确实需要允许所有子域名,可以明确写:
js
function isAllowedHost(host) {
return (
host === 'example.com' ||
host.endsWith('.example.com')
)
}
这样:
text
example.com
api.example.com
v1.api.example.com
可以通过,而:
text
example.com.evil.com
不会通过。
5.3.3 防护3 - DNS 和 IP检查
域名检查解决不了所有 SSRF。
例如:
https://example.com 即使通过了域名检查,DNS 解析后仍可能指向 127.0.0.1。
程序检查的是:
text
example.com
真正建立连接的却是:
text
127.0.0.1
所以允许访问任意公网域名的情况下,需要继续检查 DNS 解析结果。
常见的 IPv4 内部地址包括:
text
127.0.0.0/8 Loopback
10.0.0.0/8 Private
172.16.0.0/12 Private
192.168.0.0/16 Private
169.254.0.0/16 Link-local
IPv6 也需要覆盖:
text
::1 Loopback
fc00::/7 Unique Local Address
fe80::/10 Link-local
还需要注意 IPv4-mapped IPv6,例如:
text
::ffff:127.0.0.1
如果只用一个 IPv4 正则判断:
js
/^127\./
这类地址就可能被遗漏。
因此这里不适合继续堆字符串正则。
实际项目应该使用成熟的 IP 地址解析能力,将 DNS 返回的每个地址转换成标准 IP,再判断它属于哪一个地址范围。
5.3.4 防护4 - Port校验
网络目标还包括端口。
例如:
text
127.0.0.1:6379
127.0.0.1:8080
127.0.0.1:9222
不同端口对应不同服务。
如果业务只访问 HTTPS 443:
js
if (url.port && url.port !== '443') {
throw new Error('port not allowed')
}
固定 API 的场景可以直接限制:
text
https
+
api.example.com
+
443
目标越具体,攻击面越小。
5.4 Redirect:第一跳安全,后面未必安全
假设允许:
text
https://example.com/file.zip
服务器返回:
http
HTTP/1.1 302 Found
Location: http://127.0.0.1:6379/
如果客户端自动跟随:
example.com 302 → 127.0.0.1:6379。
最初通过检查的 URL 就失去了意义。
所以处理高风险 URL Fetch 时,可以关闭自动重定向:
js
const response = await fetch(url, {
redirect: 'manual'
})
收到 Location 后重新执行 URL 校验。
逻辑变成:

Redirect 不能继承上一跳的信任。
5.5 加固版 safeFetch
把前面的检查组合起来,可以得到一个简单的加固版本:
js
import dns from 'node:dns/promises'
import net from 'node:net'
const ALLOWED_HOSTS = new Set([
'api.example.com',
'cdn.example.com'
])
function isPrivateIp(ip) {
if (net.isIPv4(ip)) {
const [a, b] = ip.split('.').map(Number)
return (
a === 10 ||
a === 127 ||
a === 169 && b === 254 ||
a === 192 && b === 168 ||
a === 172 && b >= 16 && b <= 31
)
}
if (net.isIPv6(ip)) {
const normalized = ip.toLowerCase()
return (
normalized === '::1' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe8') ||
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
)
}
return true
}
async function validateTarget(input) {
const url = new URL(input)
if (url.protocol !== 'https:') {
throw new Error('unsupported protocol')
}
if (url.port && url.port !== '443') {
throw new Error('port not allowed')
}
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new Error('host not allowed')
}
const addresses = await dns.lookup(url.hostname, {
all: true,
verbatim: true
})
for (const { address } of addresses) {
if (isPrivateIp(address)) {
throw new Error('private address not allowed')
}
}
return url
}
async function safeFetch(input, maxRedirects = 3) {
let current = input
for (let i = 0; i <= maxRedirects; i++) {
const url = await validateTarget(current)
const response = await fetch(url, {
redirect: 'manual'
})
if (
response.status < 300 ||
response.status >= 400
) {
return response
}
const location = response.headers.get('location')
if (!location) {
throw new Error('redirect without location')
}
current = new URL(location, url).href
}
throw new Error('too many redirects')
}
这个例子展示的是 SSRF 防护的基本结构:
即图中的每一环:Scheme → Host → Port → DNS → IP;发生 Redirect 时,解析 Location 再检查,通过才发下一跳。
它适合作为安全模型示例,并不等同于一个完整的生产级网络安全组件。
其中还有一个需要注意的问题:
DNS 查询 → 检查 IP → 真正连接。查的是解析后的 IP,不是域名。
如果两者之间再次发生 DNS 解析,就可能出现 DNS Rebinding / TOCTOU。
对高安全等级的场景,需要进一步保证实际连接使用的地址就是已经检查过的地址。
因此,生产项目中的 SSRF 防护应该基于实际网络库的连接行为设计,而不是简单复制这段示例。
5.6 更好的设计:Main 暴露业务能力
如果应用真正需要的是:
下载用户选择的文件
接口可以设计成:
js
ipcMain.handle('file:download', async (_, fileId) => {
return downloadFile(fileId)
})
而不是:
js
ipcMain.handle('url:fetch', async (_, url) => {
return fetch(url)
})
两者的攻击面完全不同。

后者把任意 URL 直接递给 Main,攻击面是"任意网络目标"------上图中的红色路径。
Preload 也应该暴露业务接口:
js
contextBridge.exposeInMainWorld('file', {
download: (fileId) => ipcRenderer.invoke('file:download', fileId)
})
而不是:
js
contextBridge.exposeInMainWorld('network', {
fetch: (url) => ipcRenderer.invoke('url:fetch', url)
})
前者把 Renderer 的输入限制在业务对象上。
后者直接给 Renderer 一个网络出口。
5.7 TLS:证书错误
HTTPS 解决的是加密和服务器身份验证。
Electron 中最危险的一类代码是主动绕过证书验证:
js
app.on(
'certificate-error',
(event, webContents, url, error, certificate, callback) => {
event.preventDefault()
callback(true)
}
)
callback(true) 会接受当前证书。
如果网络中存在攻击者:
Electron → 攻击者 → 真实服务器。
伪造证书一旦被应用接受,中间人就可以进入通信链路。
另一个常见方式是启动参数:
--ignore-certificate-errors。
这会直接改变 Chromium 的证书错误处理行为。
生产环境中,正常的 HTTPS 请求应该使用 Electron 默认的证书验证机制。
开发环境如果使用自签证书,可以限制到开发构建和明确的开发主机:
js
if (
isDevBuild &&
DEV_HOSTS.includes(new URL(url).hostname)
) {
event.preventDefault()
callback(true)
}
更理想的开发环境方案是使用测试 CA,让开发环境本身完成正常的证书验证。
这一点尤其值得注意:
证书验证属于网络身份验证的一部分,不是一个为了兼容开发环境而随手打开的配置项。
5.8 Renderer 的网络出口:Session
Renderer 的请求经过 Chromium Session。
Electron 提供的 webRequest 可以在 Session 层观察和控制请求。
例如应用只允许:
text
api.example.com
cdn.example.com
可以:
js
const ALLOWED_HOSTS = new Set([
'api.example.com',
'cdn.example.com'
])
session.defaultSession.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(details, callback) => {
try {
const url = new URL(details.url)
callback({
cancel: !ALLOWED_HOSTS.has(url.hostname)
})
} catch {
callback({ cancel: true })
}
}
)
Renderer 发起:
js
fetch('https://evil.example.com/collect')
请求路径变成:
恶意请求经过 Renderer → Chromium → Session → webRequest,在策略层被取消。
这层和 CSP 的作用不同。
CSP 管"页面允许加载什么",webRequest 管"请求实际是否发送"。
两层可以同时存在。
5.9 多 Session:defaultSession 不代表所有页面
Electron 应用可以使用多个 Session。
例如:
js
new BrowserWindow({
webPreferences: {
partition: 'persist:account'
}
})
这个窗口使用的就不是 defaultSession。
如果网络限制只配置在:
js
session.defaultSession.webRequest
那么 persist:account 对应的 Session 并不会自动继承这套逻辑。
应用里可能存在:
text
defaultSession
persist:account
persist:workspace
临时 partition
因此网络策略应该和 Session 生命周期绑定。
可以简单理解为:
BrowserWindow → Session → 网络策略。
每个实际承载页面的 Session 都应该经过审查。
5.10 webRequest 也会影响页面导航
下面这种规则:
js
session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
...
)
会看到的不只是:
text
fetch
XHR
图片
脚本
WebSocket
还可能包括页面导航。
例如应用存在 OAuth:
Electron → accounts.example.com → 登录 → callback。
如果白名单只有:
text
api.example.com
cdn.example.com
OAuth 页面也可能被拦截。
因此实际项目需要明确:
text
主 Frame 导航
子资源
API 请求
WebSocket
哪些属于业务允许的网络范围。
如果应用本身需要加载第三方登录页面、支付页面或其他外部页面,Session 网络策略需要为这些页面单独设计。
5.11 代理:请求经过哪里
Renderer 的 Chromium 网络请求可能经过:
text
系统代理
PAC
企业代理
固定代理
例如:
Renderer → Chromium → System Proxy → Internet。
HTTPS 可以保护请求内容和服务器身份,但代理仍然能够看到连接目标、时间、流量大小等网络元数据。
因此代理本身也是应用网络模型的一部分。
Electron 可以显式设置 Session 的代理模式:
js
await session.defaultSession.setProxy({
mode: 'direct'
})
如果切换代理配置,还需要处理已有连接:
js
await session.defaultSession.closeAllConnections()
否则已经建立的连接可能继续使用原来的网络路径。
Electron 支持的代理模式包括:
text
direct
auto_detect
pac_script
fixed_servers
system
实际应用可以根据运行环境选择。
例如:
- 个人环境 → system
- 企业环境 → system / PAC / fixed_servers
- 特殊业务 → direct
Node 网络栈和 Chromium 网络栈
Main 中的网络请求还需要区分具体 API。

图中两条路径行为不同:Node fetch / http / https 走 Node 网络栈,不经过 Session 和 webRequest;net.fetch / net.request 走 Chromium 网络栈,受 Session 与代理约束。
所以:
"Main 请求不走代理"
不能作为一个通用结论。
使用 Node 网络 API 和 Electron net API,网络行为并不相同。
同样,想通过 Main 绕过系统代理也不是普适的安全方案。
企业环境可能要求:
所有外部流量 → 企业代理 → TLS 审计 → DLP / 安全策略
那么就需要设置:direct。
反而可能绕过企业网络控制。
因此代理策略应该根据实际威胁模型和部署环境确定:
- 恶意公共 Wi-Fi:关注中间人和代理劫持
- 企业环境:关注代理审计和合规
- 特殊网络:关注固定出口和访问控制
代理不是简单的"开"或者"关",它是网络路径的一部分。
5.12 XSS → IPC → SSRF:攻击链
把前面的内容连起来,就能看到 Electron 网络攻击真正危险的地方。
假设 Renderer 存在 XSS:
XSS → Renderer 被控制。
Preload 暴露:
js
contextBridge.exposeInMainWorld('api', {
fetchUrl: (url) => ipcRenderer.invoke('url:fetch', url)
})
Main:
js
ipcMain.handle('url:fetch', async (_, url) => {
const response = await fetch(url)
return {
status: response.status,
body: await response.text()
}
})
攻击链就形成了:

这里真正的问题不是某一个 API。
XSS + Preload + IPC + Main 网络能力,四者叠加。
组合起来,才形成了一条完整的攻击路径。
这也是 Electron 安全和普通 Web 安全之间非常重要的区别。
Renderer 的漏洞最终能够造成多大影响,取决于它还能通过 Preload 和 IPC 调用哪些能力。
5.13 网络请求安全模型
把这一章的内容放在一起,可以得到一个完整的网络安全模型:

对应到实际代码:
| 问题 | 主要检查位置 |
|---|---|
| 谁发起请求 | Renderer / Main |
| 使用哪套网络栈 | Node / Chromium |
| 能访问哪些地址 | Scheme / Host / Port |
| DNS 最终解析到哪里 | IP |
| 是否可以跳到其他地址 | Redirect |
| 服务器身份是否可信 | TLS / Certificate |
| 网络经过哪里 | Proxy |
| Renderer 是否能调用网络能力 | Preload / IPC |
| 所有页面是否受到同样策略 | Session |
5.14 检查清单
- Renderer 和 Main 的网络请求是否分别审查?
- Main 使用的是 Node 网络栈还是 Chromium 网络栈?
- Main 是否存在任意 URL Fetch API?
- URL 是否限制 Scheme?
- Host 是否使用明确的 Allowlist?
- Port 是否受到限制?
- 任意公网 URL 场景下是否检查 DNS 解析结果?
- IPv4 和 IPv6 是否都覆盖?
- Loopback、Private、Link-local 地址是否被处理?
- IPv4-mapped IPv6 是否考虑?
- Redirect 是否逐跳重新检查?
- 是否可以用业务 ID 替代任意 URL?
- 是否存在
certificate-error中直接callback(true)? - 是否使用
--ignore-certificate-errors? - Renderer Session 是否配置了必要的网络限制?
- 应用是否存在多个 Session?
- 每个 Session 是否都经过网络策略审查?
-
webRequest是否会影响 OAuth、支付等第三方页面? - 应用的代理策略是否明确?
- 修改代理后是否处理已有连接?
- 是否检查过
XSS → Preload → IPC → Main → Network这条攻击链?
本章总结
Electron 的网络请求安全,核心是四件事情:
- 谁发请求?
- 使用哪套网络栈?
- 最终连接到哪里?
- 连接和网络路径是否可信?
Renderer 使用 Chromium 网络能力。
Main 则可能使用 Node 网络栈,也可能使用 Electron 的 Chromium 网络能力。
当 Main 暴露类似:
js
ipcMain.handle('url:fetch', (_, url) => ...)
这样的通用接口时,Renderer 就可能获得一个新的网络出口。
如果目标 URL 可以被攻击者控制,就需要考虑 SSRF。
完整的目标检查至少涉及:
一次请求依次经过 Scheme → Host → DNS → IP → Port → Redirect,每一环都可能成为绕过点。
对于固定业务场景,最简单的方案通常是直接限制:
text
https
+
明确域名
+
明确端口
Renderer 侧可以利用 Session 和 webRequest 控制网络出口。
TLS 则负责服务器身份验证。
代理决定请求经过什么网络路径。
最后再回到 Electron 最重要的安全模型:
Renderer → Preload → IPC → Main → 系统能力。
网络请求只是其中的一种系统能力。
你无法控制网络上的每一个节点,但可以控制 Electron 从哪里发请求、能够连接哪里,以及接受什么样的服务器身份。
下一章
前面几章已经把 Electron 的几个核心边界逐渐建立起来:
页面 → 能力 → IPC → 网络。
但 Renderer 依然存在一个最经典的攻击入口:
XSS。
在普通 Web 页面中:
XSS → 控制页面。
已经是一场安全事故。
在 Electron 中,它还可能继续向下:
XSS → Renderer → Preload → IPC → Main → 文件 / 网络 / 进程。
第六章从 innerHTML、Markdown、富文本和模板拼接开始,看看一个普通的 XSS 是怎样进入 Electron 攻击链的。