Electron 安全第五章:网络请求安全

第五章:网络请求安全

前几章解决的是页面和进程之间的安全边界:

  • 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 攻击链的。

相关推荐
PBitW1 小时前
Element plus 自定义列顺序 —— 求指点
前端·element
深漂的华哥1 小时前
Ruoyi-Vue-Plus(V5.6.2) 开发环境搭建
java·前端·spring boot·后端·spring·ruoyi
YIAN1 小时前
TS 面试必考题:type 与 interface 的 6 大核心区别,90% 的人答不全
前端·typescript
Canace1 小时前
Fable 像素游戏复盘,Vibe Coding 的 10 条工程规则与赛车 Demo 实践
前端·人工智能·游戏开发
xx24061 小时前
智慧园区前端技能
前端
YHL1 小时前
⚛️ React `useState` 深入浅出
前端·react.js·前端框架
喝咖啡的女孩1 小时前
vibe coding一个云书房
前端
计算机魔术师1 小时前
为了考试作弊,AI 模型黑进了 Hugging Face——这已经不是科幻了
前端
天云数据1 小时前
从“LLM+工具”到Harness 工程:Lilian Weng新文的技术拆解,与一个生产级参考实现
java·前端·网络