使用 Electron 很重要的一点是要理解 Electron 不是一个 Web 浏览器。 它允许您使用熟悉的 Web 技术构建功能丰富的桌面应用程序,但是您的代码具有更强大的功能。 JavaScript 可以访问文件系统,用户 shell 等。 这允许您构建更高质量的本机应用程序,但是内在的安全风险会随着授予您的代码的额外权力而增加。

事实上,最流行的 Electron 应用程序(Atom,Slack,Visual Studio Code 等) 主要显示本地内容(即使有远程内容也是无 Node 的、受信任的、安全的内容) - 如果您的应用程序要运行在线的源代码,那么您需要确保源代码不是恶意的。

一、preload

preload是electron的预加载机制,可以理解为在electron创建时将nodejs环境加载到渲染进程中使用。程序的进程是相互独立的,electron中渲染进程和主进程的协同工作一般采用IPC进程通信或者在渲染进程中集成node环境,还有早期比较低效的remote模块方式使用node环境。除非保证渲染进程的JavaScript都是可信安全的,否则不推荐在渲染进程集成node环境。使用preload的目的是在electron中不开启node环境集成情况下使用node的模块,避免不安全的JavaScript代码随意使用node环境。
preload的工作是在创建窗体时预加载需要node模块到渲染进程,然后以API方式暴露给渲染进程调用,preload共享渲染进程的window、document对象,因此preload可以轻松操作DOM,而渲染进程不共享preload的global对象。

preload.js

const { contextBridge, ipcRenderer} = require('electron');
const fs = require('fs'); 

contextBridge.exposeInMainWorld('fsApi', {
    writeFile: (filename, text, callback) => {
        fs.writeFile(filename, text, callback);
    },
    readFile: (filename, encode,  callback) => {
        fs.readFile(filename, encode, callback);
    },
    unlink: (filename, callback) => {
        fs.unlink(filename, callback);
    }
});

contextBridge.exposeInMainWorld('ipcRendererApi', {
    send: (channel, args) => ipcRenderer.send(channel, args),
    once: (channel, listener) => ipcRenderer.once(channel, listener),
    on: (channel, listener) => ipcRenderer.on(channel, listener),
});

调用预加载API

ipcRendererApi.send('close', args)

二、安全策略

(一)webPreferences属性

Electron安全策略几乎都在webPreferences属性中设置,通过属性来开启或者关闭渲染进程相关安全选项。

属性默认作用说明
nodeIntegration

nodeIntegrationInWorker
true/falsefalse渲染进程集成node环境默认关闭,当渲染进程加载远程内容时必须关闭,远程内容不确定性,避免跳过渲染进程在node环境下执行JS脚本。webview中同样不推荐使用nodeIntegration属性。<webview nodeIntegration src="page.html"></webview>
preload预加载脚本当禁用Node.js集成时,你依然可以暴露API给你的站点以使用Node.js的模块功能或特性。即预加载node模块到渲染进程使用,另外预加载的模块可以直接访问渲染进程的DOM。
electron早期继承remote模块,用于在渲染进程调用主进程的功能,当前最新的electron已经不是默认模块需要另外安装,使用remote模块相比较于IPC通信更低效,建议使用IPC与主进程通信。
contextIsolationtrue/falsetrue开启JS上下文隔离默认开启,即便使用了 nodeIntegration: false, 要实现真正的强隔离并且防止使用 Node.js 的功能, contextIsolation 也 必须 开启。一般情况下结合nodeIntegration一起使用。
webSecuritytrue/falsetrue安全性功能在渲染进程(BrowserWindow、BrowserView 和 <webview>)禁用webSecurity,这将使得来自其他站点的非安全代码被执行。
allowRunningInsecureContenttrue/falsefalse允许运行不安全内容默认情况下,Electron不允许网站在HTTPS中加载或执行非安全源(HTTP) 中的脚本代码、CSS或插件。 将allowRunningInsecureContent属性设为true将禁用这种保护。
experimentalFeaturestrue/falsefalse实验性功能实验性功能是实验性的,尚未对所有 Chromium 用户启用。
enableBlinkFeaturesBlink渲染引擎特性通常来说,某个特性默认不被开启肯定有其合理的原因。 针对特定特性的合理使用场景是存在的。 作为开发者,你应该非常明白你为何要开启它,有什么后果,以及对你应用安全性的影响。 在任何情况下都不应该推测性的开启特性。

(二)加载安全内容

在渲染进程中只加载HTTPS的安全内容

main.js (Main Process)

// 不推荐
browserWindow.loadURL ('http://example.com')
// 推荐 
browserWindow.loadURL ('https://example.com')

index.html (Renderer Process)

<!-- 不推荐 -->
<script crossorigin src="http://example.com/react.js"></script>
<link rel="stylesheet" href="http://example.com/style.css">

<!-- 推荐 -->
<script crossorigin src="https://example.com/react.js"></script>
<link rel="stylesheet" href="https://example.com/style.css">

(三)启用进程沙盒化

您应该在所有渲染器中启用沙盒。 不建议在一个未启动沙盒的进程(包括主进程)中加载、阅读或处理任何不信任的内容。Electron20及以后的版本默认开启沙盒,这导致preload加载nodejs的插件报错,在确保当前程序的代码安全性的前提下可以手动关闭沙盒。详情

(四)限制请求会话权限

通过session来过滤请求协议或者域名,electron该API基于Chromium permissions API实现。

main.js (Main Process)

const { session } = require('electron')
const URL = require('url').URL

session
  .fromPartition('some-partition')
  .setPermissionRequestHandler((webContents, permission, callback) => {
    const parsedUrl = new URL(webContents.getURL())

    if (permission === 'notifications') {
      // Approves the permissions request
      callback(true)
    }

    // Verify URL
    if (parsedUrl.protocol !== 'https:' || parsedUrl.host !== 'example.com') {
      // Denies the permissions request
      return callback(false)
    }
  })

(五)Content-Security-Policy(CSP)

CSP是HTTP协议标头,限制允许渲染进程执行的脚本,是应对跨站脚本攻击和数据注入攻击的策略。

// 不推荐
Content-Security-Policy: '*'

// 推荐
Content-Security-Policy: script-src 'self' https://apis.example.com

main.js (Main Process)

const { session } = require('electron')

session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Content-Security-Policy': ['default-src \'none\'']
    }
  })
})

index.html (Renderer Process)

<meta http-equiv="Content-Security-Policy" content="default-src 'none'">

仅适用于HTTP协议

(六)Webview的allowpopups属性

index.html (Renderer Process)

<!-- 不推荐 -->
<webview allowpopups src="page.html"></webview>

<!-- 推荐 -->
<webview src="page.html"></webview>

(七)Webview标签选项

渲染进程通过标签创建的webview没有node继承,而从主进程创建的webview可以加成node。在 标签生效前,Electron将产生一个will-attach-webview事件到webContents中。 利用这个事件来阻止可能含有不安全选项的 webViews 创建。

main.js (Main Process)

app.on('web-contents-created', (event, contents) => {
  contents.on('will-attach-webview', (event, webPreferences, params) => {
    // Strip away preload scripts if unused or verify their location is legitimate
    delete webPreferences.preload

    // Disable Node.js integration
    webPreferences.nodeIntegration = false

    // Verify URL being loaded
    if (!params.src.startsWith('https://example.com/')) {
      event.preventDefault()
    }
  })
})

(八)禁用或限制网页跳转

该功能主要是防止跳转到指定页面后执行JS,从而存在跨站点攻击隐患,即便是关闭node集成开启上下文隔离的情况下,也不应该允许任意网页跳转。

如果您的应用不需要导航,您可以在 will-navigate 处理器中调用 event.preventDefault()。 如果您知道您的应用程序可能会导航到哪些界面,请在事件处理器中检查URL,并且仅当它与您预期的URL匹配时才进行导航。

main.js (Main Process)

const URL = require('url').URL

app.on('web-contents-created', (event, contents) => {
  contents.on('will-navigate', (event, navigationUrl) => {
    const parsedUrl = new URL(navigationUrl)

    if (parsedUrl.origin !== 'https://example.com') {
      event.preventDefault()
    }
  })
})

(九)禁止或限制新窗口创建

当打开新窗口时,注册事件来处理即将打开新窗口的URL,从而过滤掉不安全的url打开新窗口。

main.js (Main Process)

const { shell } = require('electron')

app.on('web-contents-created', (event, contents) => {
  contents.setWindowOpenHandler(({ url }) => {
    // 在此示例中我们要求操作系统
    // 在默认浏览器中打开此事件的 url。
    //
    // 关于哪些URL应该被允许通过shell.openExternal打开,
    // 请参照以下项目。
    if (isSafeForExternalOpen(url)) {
      setImmediate(() => {
        shell.openExternal(url)
      })
    }

    return { action: 'deny' }
  })
})

(十)shell.openExternal

shell.openExternal可以用来执行任意命令,不受信任的内容不要使用该API。

main.js (Main Process)

//  不好
const { shell } = require('electron')
shell.openExternal(USER_CONTROLLED_DATA_HERE)
//  好
const { shell } = require('electron')
shell.openExternal('https://example.com/index.html')

(十一)验证IPC消息发送者

任何窗口都可以向主进程发送消息,主进程回复渲染进程时,应该确定发送者身份。

main.js (Main Process)

// Bad
ipcMain.handle('get-secrets', () => {
  return getSecrets();
});

// Good
ipcMain.handle('get-secrets', (e) => {
  if (!validateSender(e.senderFrame)) return null;
  return getSecrets();
});

function validateSender(frame) {
  // Value the host of the URL using an actual URL parser and an allowlist
  if ((new URL(frame.url)).host === 'electronjs.org') return true;
  return false;
}
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐