本页目录

导航、内容与系统能力的安全边界

用自定义协议、固定资源、外链白名单、CSP 与权限拒绝解释桌面应用的多层安全边界。

L2 · 能交付约 16 分钟阅读含示例、练习与验收
本页内容

桌面应用中的内容为何会变成权限问题#

浏览器页面发生脚本注入时,攻击者通常得到该页面的 Web 能力;Electron 页面若又拥有文件系统、任意 IPC 或外部程序启动能力,后果可能越过页面进入本机资源。因此安全边界不是最后加的一段配置,而是你决定哪些内容可执行、哪些页面可请求什么动作、哪些系统能力能被触达的整体设计。

本章前置是窗口、preload 与 IPC。目标是能解释一条具体攻击链并在正确位置切断它。我们建立一个文本展示小程序,只加载固定本地资源,只允许打开官方文档范围内的外链,拒绝页面导航、新窗口和不需要的浏览器权限。模型生成内容仅作为风险例子,不接入真实模型。

“内容来自 AI”不提高可信度。模型可能复述检索到的恶意 HTML,也可能生成指向外部协议的链接;用户导入的笔记同样可能携带这些字符串。风险取决于应用怎样使用字符串:放到 textContent 是文字,放到 innerHTML 会进入 HTML 解析,传给 shell.openExternal 则可能交给系统注册程序处理。相同数据流到不同危险位置,会获得不同执行含义。

分清内容、页面与系统三个边界#

内容边界决定一段外部文字是否能成为页面代码。安全显示纯文本最简单;需要 Markdown 时,应使用经过维护的解析与净化方案,并独立限制链接、图片和嵌入资源。正则替换几个 script 标签不足以覆盖 HTML 解析规则,不能作为通用净化器。本章不实现 Markdown 渲染,直接使用文本节点避免不必要的执行解释。

页面边界决定谁可以使用桥。可信本地界面与远程网站应该有不同权限,不能把远程内容加载到拥有同一 preload 能力的窗口里,再假设用户只会访问安全页面。即使导航地址看起来与原页面相近,也必须通过明确规则,而不是字符串包含某个品牌词就放行。

系统边界决定主进程能做哪些动作。沙箱和上下文隔离并不会限制主进程自己写出的任意文件读取接口;一个宽泛的桥仍然可以把能力重新送给页面。因此每个系统动作需要业务级输入限制、发送者检查与合理频率。保护是多层合作,没有一个布尔开关能替代全部设计。

为什么从 loadFile 过渡到自定义协议#

前面的 loadFile 实验帮助你理解入口和本地文件路径,适合受控入门演示。当前官方安全指南建议实际应用用自定义协议提供本地页面,避免把 file 协议的特殊行为当作一般 Web 来源模型。这里把界面放到 app://ui/index.html,并只提供两个固定资源,展示可审查的入口边界。

自定义协议不是把磁盘目录随意映射成 URL。若将 URL 路径直接拼到磁盘根目录,攻击者可能通过路径穿越、编码差异或意外文件名探测不该暴露的文件。最小程序可以用静态 Map:某个确定路径对应某个确定文件,除此之外全部拒绝。资源多时也应由构建清单或严格解析规则管理,而不是默认共享整个工程目录。

registerSchemesAsPrivileged 在应用 ready 之前声明协议特性;具体请求处理器在对应 session 上注册。使用独立 session 的窗口必须把处理器注册到同一个 session,不能只注册默认 session 后期待所有窗口自动继承。standard 与 secure 等特性影响 URL 和 Web 安全语义,但 secure 不表示内容已经可信,也不是授予 Node 权限。

实验一:离线验证外链与资源规则#

建立 security-lab 目录,保存 policy.cjs 和 policy-check.cjs。规则只操作字符串与对象,不打开外部浏览器,也不读磁盘。执行 node policy-check.cjs,预期所有伪装来源、危险协议与非白名单资源被拒绝。

policy.cjs
'use strict';
const ASSETS = new Map([
  ['/index.html', 'index.html'],
  ['/renderer.js', 'renderer.js']
]);
function assetName(rawURL, method) {
  if (method !== 'GET' || typeof rawURL !== 'string') return null;
  try {
    const url = new URL(rawURL);
    if (url.protocol !== 'app:' || url.hostname !== 'ui' || url.port
        || url.username || url.password || url.search) return null;
    return ASSETS.get(url.pathname) || null; // 不把任意 URL 路径拼成磁盘路径
  } catch { return null; }
}
function externalDocsURL(rawURL) {
  if (typeof rawURL !== 'string' || rawURL.length > 2048) return null;
  try {
    const url = new URL(rawURL);
    if (url.protocol !== 'https:' || url.hostname !== 'www.electronjs.org'
        || url.port || url.username || url.password || url.search) return null;
    if (!/^\/docs\/latest\/[a-z0-9/_-]*$/i.test(url.pathname)) return null;
    return url.href;
  } catch { return null; }
}
function trustedFrame(event, owner) {
  return Boolean(owner && !owner.isDestroyed() && event.sender === owner.webContents
    && event.senderFrame && event.senderFrame === owner.webContents.mainFrame
    && event.senderFrame.origin === 'app://ui'
    && event.senderFrame.url === 'app://ui/index.html');
}
function allowPermission() {
  return false; // 本实验不需要摄像头、定位、浏览器剪贴板等权限
}
module.exports = { assetName, externalDocsURL, trustedFrame, allowPermission };
policy-check.cjs
'use strict';
const assert = require('node:assert/strict');
const { assetName, externalDocsURL, allowPermission } = require('./policy.cjs');
assert.equal(assetName('app://ui/index.html', 'GET'), 'index.html');
assert.equal(assetName('app://ui/renderer.js', 'GET'), 'renderer.js');
for (const address of [
  'app://other/index.html', 'app://ui/package.json', 'app://ui/../main.cjs',
  'app://ui/%2e%2e/main.cjs', 'app://ui/index.html?file=secret'
]) assert.equal(assetName(address, 'GET'), null);
assert.equal(assetName('app://ui/index.html', 'POST'), null);
assert.equal(externalDocsURL('https://www.electronjs.org/docs/latest/tutorial/security'),
  'https://www.electronjs.org/docs/latest/tutorial/security');
for (const address of [
  'javascript:alert(1)', 'file:///secret.txt', 'data:text/html,hello',
  'https://www.electronjs.org.evil.invalid/docs/latest/',
  'https://evil@www.electronjs.org/docs/latest/',
  'https://www.electronjs.org:444/docs/latest/',
  'https://www.electronjs.org/docs/latest/../../other'
]) assert.equal(externalDocsURL(address), null);
assert.equal(allowPermission(), false);
console.log('通过:固定资源、方法限制、协议/主机/凭证/端口/路径边界');

规则使用 URL 解析器后再比较协议、主机和路径,拒绝 URL 中的凭证和非标准端口。字符串 startsWith 容易把相似主机、用户名部分或路径伪装误判为允许。本例只允许官方文档路径,不接受查询参数,也不自动打开任何从内容中识别出的链接。

实验二:完整安全文本窗口#

同一目录追加以下五个文件;policy.cjs 是上方完整依赖。协议处理器只读应用自有的两个资源,preload 不由页面协议提供,而由主进程的绝对路径配置。页面只有一个打开官方文档的受限能力,没有文件读取和通用 IPC。

package.json
{
  "name": "electron-security-lab",
  "version": "1.0.0",
  "private": true,
  "main": "main.cjs",
  "scripts": { "start": "electron .", "check": "node policy-check.cjs" },
  "devDependencies": { "electron": "44.3.0" }
}
main.cjs
'use strict';
const { app, BrowserWindow, protocol, session, ipcMain, shell } = require('electron');
const path = require('node:path');
const { readFile } = require('node:fs/promises');
const { assetName, externalDocsURL, trustedFrame, allowPermission } = require('./policy.cjs');
protocol.registerSchemesAsPrivileged([{
  scheme: 'app',
  privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }
}]);
let mainWindow = null;
let lastOpen = 0;
const fail = (code, message) => ({ ok: false, error: { code, message } });
const CSP = [
  "default-src 'none'", "script-src 'self'", "style-src 'self'",
  "img-src 'none'", "connect-src 'none'", "object-src 'none'",
  "base-uri 'none'", "frame-src 'none'", "frame-ancestors 'none'",
  "form-action 'none'"
].join('; ');

ipcMain.handle('security:open-docs', async (event, address) => {
  if (!trustedFrame(event, mainWindow)) return fail('FORBIDDEN', '来源不被允许');
  const target = externalDocsURL(address);
  if (!target) return fail('BAD_URL', '只允许指定范围的官方文档地址');
  if (Date.now() - lastOpen < 2000) return fail('RATE_LIMIT', '请稍后再打开');
  lastOpen = Date.now();
  try {
    await shell.openExternal(target);
    return { ok: true };
  } catch {
    return fail('OPEN_FAILED', '系统无法打开此地址');
  }
});
function createWindow(appSession) {
  const win = new BrowserWindow({
    width: 800, height: 560,
    webPreferences: {
      session: appSession,
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true, sandbox: true, nodeIntegration: false,
      webSecurity: true, webviewTag: false
    }
  });
  mainWindow = win;
  // 页面发起的导航全部拒绝;主进程只加载下方固定入口。
  win.webContents.on('will-navigate', event => event.preventDefault());
  win.webContents.on('will-frame-navigate', event => event.preventDefault());
  win.webContents.on('will-redirect', event => event.preventDefault());
  win.webContents.on('will-attach-webview', event => event.preventDefault());
  win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  win.on('closed', () => {
    if (mainWindow === win) mainWindow = null;
  });
  void win.loadURL('app://ui/index.html').catch(error => {
    console.error('可信页面加载失败:', error.message);
    app.quit();
  });
}
app.whenReady().then(() => {
  const appSession = session.fromPartition('security-lab');
  appSession.setPermissionCheckHandler(() => allowPermission());
  appSession.setPermissionRequestHandler((_contents, _permission, callback) => {
    callback(allowPermission());
  });
  appSession.setDevicePermissionHandler(() => false);
  appSession.protocol.handle('app', async request => {
    const name = assetName(request.url, request.method);
    if (!name) return new Response('资源不被允许', { status: 403 });
    try {
      const bytes = await readFile(path.join(__dirname, name));
      const type = name.endsWith('.html') ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8';
      return new Response(bytes, {
        headers: {
          'Content-Type': type,
          'Content-Security-Policy': CSP,
          'X-Content-Type-Options': 'nosniff'
        }
      });
    } catch {
      return new Response('资源加载失败', { status: 500 });
    }
  });
  createWindow(appSession);
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow(appSession);
  });
}).catch(error => { console.error(error); app.quit(); });
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
preload.cjs
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('helpAPI', {
  openDocs: address => ipcRenderer.invoke('security:open-docs', address)
});
index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>安全文本展示</title>
  <script src="./renderer.js" defer></script>
</head>
<body>
  <main>
    <h1>内容只作为文本</h1>
    <label for="source">输入要展示的内容</label>
    <textarea id="source" rows="5" cols="50"></textarea>
    <button id="render" type="button">展示原文</button>
    <button id="docs" type="button">在系统浏览器打开官方安全文档</button>
    <p id="status" role="status"></p>
    <pre id="output"></pre>
  </main>
</body>
</html>
renderer.js
'use strict';
const source = document.querySelector('#source');
const output = document.querySelector('#output');
const status = document.querySelector('#status');
source.value = '<img src=x onerror="alert(1)"> 这是一段外部文字';
function render() {
  output.textContent = source.value; // 不把外部文字交给 HTML 解析器
}
document.querySelector('#render').addEventListener('click', render);
document.querySelector('#docs').addEventListener('click', async () => {
  try {
    const reply = await window.helpAPI.openDocs(
      'https://www.electronjs.org/docs/latest/tutorial/security'
    );
    status.textContent = reply.ok ? '已请求系统浏览器打开' : reply.error.message;
  } catch { status.textContent = '打开文档通信失败'; }
});
render();
sh
node policy-check.cjs
npm install
npm start

预期页面把 img 字样完整显示为文本,没有图片加载或脚本执行;点击文档按钮才请求系统浏览器打开固定范围的地址。应用本身可以离线展示,外部文档需要网络。这里没有实际点击外链,也没有启动 GUI;已执行的是离线规则和脚本语法检查。

导航与外部打开为什么分开处理#

will-navigate 处理页面发起的主框架导航,不会自动覆盖主进程程序调用的 loadURL、loadFile 等入口。示例一方面拒绝页面导航,另一方面让主进程只加载固定 URL。若以后写出 loadURL(userInput),前者不会神奇地保护这个新入口,因此所有可改变页面来源的代码都必须有自己的约束。

setWindowOpenHandler 管理新窗口请求,示例一律 deny。不要在拒绝新窗口后无条件把同一个 URL 交给 shell.openExternal,那只是把危险动作搬到系统层。需要外部打开时,让用户明确触发受限业务接口,并在主进程解析 URL。系统会按协议选择处理程序,所以 javascript、file 或任意自定义协议都不该被通用放行。

allowlist 限制最初请求的外部地址,不控制系统浏览器后续导航或网站重定向。选择可信固定帮助站点能降低风险,但不等于给第三方网页提供安全证明。若未来展示模型生成的任意引用链接,应先让用户看见目标,再使用针对产品需求的协议与来源策略;不要把本例文档白名单改成“包含 http 就行”。

CSP、浏览器权限与 Node 权限不是同一层#

CSP 限制资源加载和脚本执行来源。示例只允许自有脚本、拒绝网络连接、嵌入框架与对象,并通过协议响应头设置策略。frame-ancestors 需要响应头语境,不能把它放在 meta 中就假定生效。前面基础实验的 meta CSP 与本章响应头策略服务于不同复杂度,应该按实际加载方式核对效果。

CSP 不是 HTML 净化器,也不会限制主进程读取文件。即使脚本只能来自 self,如果你的自有脚本把不可信内容送进危险接口,策略也不理解业务意图。因此 textContent、最小能力桥和主进程校验仍然必要。把这几层职责分开,才能解释某个攻击为什么被拦截,而不是只说“加了 CSP 所以安全”。

session 的权限检查和权限请求处理器控制相应浏览器能力。本例两者都拒绝,并另设设备权限拒绝,因为程序不需要定位、摄像头或设备接入。不能只写请求处理器就假定所有检查路径都遵守相同策略;也不能把这里的拒绝误解为主进程失去了 Node 文件能力。主进程仍由应用代码管理权限。

webSecurity 默认 true,应保持开启。关闭它会削弱同源保护,并可能联动不安全内容配置;它不是解决跨域请求失败的正常方案。需要远程 API 时,应明确服务端 CORS、可信主进程请求适配和数据合同,而不是为一个请求关闭整个窗口的 Web 安全模型。

旧教程的 remote 为什么不适合继续搬用#

内置 remote 模块在 Electron 14 已移除。旧写法让 renderer 像调用本地对象一样接触主进程能力,容易隐藏跨进程调用的成本与权限范围。当前入门应使用明确的 IPC 方法,不应通过恢复类似全能代理来逃避合同设计。社区替代包的存在不改变本章最小能力原则。

同样,旧的 new-window 事件、旧协议注册方法和历史安全默认值需要按版本重新核对。固定 44.3.0 是为了复现本教材,不是建议永远停在这个版本;Electron 所带 Chromium 和 Node 的安全修复依赖应用升级,用户安装最新系统浏览器并不会自动修补你的旧 Electron 运行时。

实验三与练习:同地址子框架不能继承主窗口权限#

练习验证 trustedFrame:主窗口主框架通过,其他窗口、相同地址的子框架、缺失框架、来源异常都拒绝。提示是 URL 字符串相同不代表框架对象身份相同;你需要同时检查宿主对象与来源。

参考答案:完整发送者反例测试

在同一目录保存 frame-check.cjs,执行 node frame-check.cjs。它验证规则函数,不伪造真实 Electron 的安全边界。

frame-check.cjs
'use strict';
const assert = require('node:assert/strict');
const { trustedFrame } = require('./policy.cjs');
const frame = { url: 'app://ui/index.html', origin: 'app://ui' };
const contents = { mainFrame: frame };
const owner = { webContents: contents, isDestroyed: () => false };
assert.equal(trustedFrame({ sender: contents, senderFrame: frame }, owner), true);
assert.equal(trustedFrame({ sender: {}, senderFrame: frame }, owner), false);
assert.equal(trustedFrame({ sender: contents, senderFrame: { ...frame } }, owner), false);
assert.equal(trustedFrame({ sender: contents, senderFrame: null }, owner), false);
frame.origin = 'null';
assert.equal(trustedFrame({ sender: contents, senderFrame: frame }, owner), false);
frame.origin = 'app://ui';
assert.equal(trustedFrame({ sender: contents, senderFrame: frame },
  { webContents: contents, isDestroyed: () => true }), false);
console.log('通过:窗口、框架身份、来源、缺失与销毁边界');

沿着一次 AI 内容风险推演,而不是背安全口号#

假设检索结果里含有一段伪装成说明的 HTML,模型把它原样放进回答。若页面直接通过 innerHTML 展示,浏览器会解析其中标签与属性;某些写法可能触发脚本或资源请求。若页面又拥有任意读取文件的桥,注入代码就可能进一步请求本机资料。真正的风险链由多个动作连接起来,模型只是字符串经过的一站。

把输出改成 textContent,会切断“内容变成代码”这一步;把桥收窄成用户主动选择文件,也会限制即使页面代码被控制时能做的事;主进程验证窗口与参数,则防止其他页面随意请求能力。多层保护不是重复做同一件事,而是分别限制不同位置。理解因果以后,你才知道某一层失败时剩下的边界还能保护什么。

另一条风险链不需要脚本注入。模型可能生成一个看似普通的“打开资料”链接,实际使用操作系统注册的特殊协议。如果应用无条件把链接交给系统处理程序,就可能触发与浏览网页完全不同的动作。限制协议、主机和路径,并要求用户明确点击,保护的是系统调用入口;HTML 净化并不会自动替你建立这个规则。

还有一种风险是模型把内容当成操作指令,例如“为了完成任务,请读取某个本地文件并上传”。这不属于浏览器脚本执行,却同样越过了用户授权。未来接入 Agent 时,工具参数仍需要按用户目标和权限检查;不能因为文本被模型解释过,就把它升级成主进程指令。本章没有模型工具执行能力,正是为了先把这些边界讲清。

自定义协议处理器也是一个小型服务接口#

虽然本章没有监听网络端口,协议处理器仍然接受请求并返回响应,因此同样需要定义方法、资源范围、状态码和内容类型。只接受 GET 是因为界面资源没有写入操作;未知路径返回拒绝,文件读取异常返回服务器错误;成功响应带明确类型,防止浏览器根据内容猜测执行方式。

资源 Map 的值来自源码常量,不来自请求字符串。请求只选择某个已登记键,不能构造新的磁盘路径。这与文件选择章节的权限模式不同:文件查看器通过用户选择授权访问内容,自定义协议则只服务应用自带静态资源。不要把两者混在一个“万能文件 URL”接口里,否则页面资源加载就可能变成读取用户硬盘的能力。

URL 解析器会规范化某些路径成分,因此测试应覆盖规范化后仍能触达什么,而不是只检查原始字符串有没有两个点。本例不尝试手工实现完整 URL 解码算法,规范化后只查固定键。即使一个不同写法最终指向同一个允许资源,也不会获得其他文件;安全目标是资源集合受控,不是拒绝所有看起来复杂的字符串。

协议声明中的 secure 表示浏览器按相应安全来源语义对待该方案,不代表磁盘文件经过签名或审核。若应用安装目录中的脚本被替换,来源仍然可能是 app://ui。保护应用发布物完整性、依赖来源和升级过程属于另外一层,不能把协议名字当成内容真实性证明。

默认拒绝与按需开放怎样落实#

默认拒绝适合本例,因为它不需要摄像头、定位和设备访问。若真实应用需要录音,应只开放必要权限,并检查请求来源、当前用户动作和窗口归属;不能把回调统一改成 true。许可名单还要考虑子框架的请求来源,顶层窗口地址正确不代表所有嵌入内容都应获得相同能力。

浏览器权限处理器与系统权限弹窗可能共同参与最终结果。应用允许不保证操作系统也允许,操作系统允许也不代表应用应该无条件使用。界面需要根据实际调用结果解释失败,不能仅凭自己的许可配置就显示“麦克风已可用”。这与通知章节中“已提交不等于已看见”的区别相同,都是证据与状态对应的问题。

对于当前不需要的能力,最容易审查的实现是根本不暴露对应桥方法。把能力留在主进程模块里但没有入口,与通过桥公开后只靠页面隐藏按钮,权限范围完全不同。后者让任何能在页面运行的代码都可以尝试调用,因此新增桥方法应该像新增服务接口一样审查输入、输出、资源和副作用。

安全验证应包含反例和真实宿主两类证据#

离线测试可以稳定验证 URL 解析、字段校验和对象身份规则,适合覆盖危险协议、相似域名、缺失框架和路径穿越等反例。它们不需要真的打开恶意网页,也不需要访问私人文件。测试的意义在于你自己写的规则在已知反例下确实拒绝,而不是靠注释声称它会拒绝。

但模拟对象不能证明 Chromium 在真实窗口中如何触发导航事件,也不能证明自定义协议的 CSP 头已经生效。真实验收需要启动应用,观察允许脚本加载、外部脚本被拦、页面不能新建窗口、不需要的权限请求被拒绝,以及关闭窗口后的资源释放。本轮没有执行这些 GUI 步骤,所以结论明确停在源代码合同和离线规则层面。

安全配置也需要随依赖版本复查。升级可能改变默认值、事件参数或系统 API 返回方式;本组在剪贴板和导航事件中已经遇到具体合同变化。更可靠的维护方式是保留版本锁定、官方来源和反例测试,在升级时针对变更重验,而不是把一份曾经工作过的模板永久视为安全基线。

掌握标准、自测与来源#

掌握标准是能追踪“外部字符串进入页面解析器,再调用桥,再触发系统动作”的链,并分别提出对应边界。你应知道每条规则的作用范围,不能把一次正则检查或一个安全选项当作所有输入的保护。

自测一:禁止 window.open 后无条件 openExternal 安全吗?不安全,系统协议处理可能更危险。自测二:CSP 会验证 IPC 参数吗?不会。自测三:模型生成的 HTML 可以直接信任吗?不能,来源不改变最终执行位置的风险。

官方来源包括 安全指南协议 APIsession 权限shellwebContentsElectron 14 移除 remoteWebPreferences。离线规则及框架反例实际执行,完整 Electron 文件仅语法检查;真实 CSP 执行、权限回调、导航拦截与系统外链尚未做 GUI 验收。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭