使用 Electron 很重要的一点是要理解 Electron 不是一个 Web 浏览器。 它允许您使用熟悉的 Web 技术构建功能丰富的桌面应用程序,但是您的代码具有更强大的功能。 JavaScript 可以访问文件系统,用户 shell 等。 这允许您构建更高质量的本机应用程序,但是内在的安全风险会随着授予您的代码的额外权力而增加。
事实上,最流行的 Electron 应用程序(Atom,Slack,Visual Studio Code 等) 主要显示本地内容(即使有远程内容也是无 Node 的、受信任的、安全的内容) - 如果您的应用程序要运行在线的源代码,那么您需要确保源代码不是恶意的。
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)
Electron安全策略几乎都在webPreferences属性中设置,通过属性来开启或者关闭渲染进程相关安全选项。
属性 | 值 | 默认 | 作用 | 说明 |
---|---|---|---|---|
nodeIntegration 或 nodeIntegrationInWorker |
true/false | false | 渲染进程集成node环境 | 默认关闭,当渲染进程加载远程内容时必须关闭,远程内容不确定性,避免跳过渲染进程在node环境下执行JS脚本。webview中同样不推荐使用nodeIntegration属性。
|
preload | 预加载脚本 | 当禁用Node.js集成时,你依然可以暴露API给你的站点以使用Node.js的模块功能或特性。即预加载node模块到渲染进程使用,另外预加载的模块可以直接访问渲染进程的DOM。 electron早期继承remote模块,用于在渲染进程调用主进程的功能,当前最新的electron已经不是默认模块需要另外安装,使用remote模块相比较于IPC通信更低效,建议使用IPC与主进程通信。 |
||
contextIsolation | true/false | true | 开启JS上下文隔离 | 默认开启,即便使用了 nodeIntegration: false, 要实现真正的强隔离并且防止使用 Node.js 的功能, contextIsolation 也 必须 开启。一般情况下结合nodeIntegration一起使用。 |
webSecurity | true/false | true | 安全性功能 | 在渲染进程(BrowserWindow、BrowserView 和 )禁用webSecurity,这将使得来自其他站点的非安全代码被执行。 |
allowRunningInsecureContent | true/false | false | 允许运行不安全内容 | 默认情况下,Electron不允许网站在HTTPS中加载或执行非安全源(HTTP) 中的脚本代码、CSS或插件。 将allowRunningInsecureContent属性设为true将禁用这种保护。 |
experimentalFeatures | true/false | false | 实验性功能 | 实验性功能是实验性的,尚未对所有 Chromium 用户启用。 |
enableBlinkFeatures | Blink渲染引擎特性 | 通常来说,某个特性默认不被开启肯定有其合理的原因。 针对特定特性的合理使用场景是存在的。 作为开发者,你应该非常明白你为何要开启它,有什么后果,以及对你应用安全性的影响。 在任何情况下都不应该推测性的开启特性。 |
在渲染进程中只加载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)
}
})
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协议
index.html (Renderer Process)
<webview allowpopups src="page.html">webview>
<webview src="page.html">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可以用来执行任意命令,不受信任的内容不要使用该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')
任何窗口都可以向主进程发送消息,主进程回复渲染进程时,应该确定发送者身份。
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;
}