From ef297c029946031b063890233b9aa9eb607dbb60 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 27 Jan 2022 17:43:00 +0100 Subject: refactor: Extract main window hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This code is security critical, so it should be properly extracted to enable testing. Signed-off-by: Kristóf Marussy --- packages/main/src/index.ts | 57 ++------------ .../infrastructure/electron/impl/hardenSession.ts | 87 ++++++++++++++++++++++ .../electron/impl/lockWebContentsToFile.ts | 59 +++++++++++++++ 3 files changed, 151 insertions(+), 52 deletions(-) create mode 100644 packages/main/src/infrastructure/electron/impl/hardenSession.ts create mode 100644 packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 128ae35..2072017 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -22,7 +22,6 @@ import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { arch } from 'node:os'; -import { URL } from 'node:url'; import { ServiceToMainIpcMessage, @@ -41,11 +40,12 @@ import { getSnapshot, onAction, onPatch } from 'mobx-state-tree'; import osName from 'os-name'; import { - DEVMODE_ALLOWED_URL_PREFIXES, enableStacktraceSourceMaps, installDevToolsExtensions, openDevToolsWhenReady, } from './infrastructure/electron/impl/devTools'; +import hardenSession from './infrastructure/electron/impl/hardenSession'; +import lockWebContentsToFile from './infrastructure/electron/impl/lockWebContentsToFile'; import getDistResources from './infrastructure/resources/impl/getDistResources'; import initReactions from './initReactions'; import { createMainStore } from './stores/MainStore'; @@ -128,32 +128,6 @@ initReactions(store) log.log('Failed to initialize application', error); }); -const rendererBaseURL = resources.getRendererURL('/'); -function shouldCancelMainWindowRequest(url: string, method: string): boolean { - if (method !== 'GET') { - return true; - } - let normalizedURL: string; - try { - normalizedURL = new URL(url).toString(); - } catch { - return true; - } - if ( - isDevelopment && - DEVMODE_ALLOWED_URL_PREFIXES.some((prefix) => - normalizedURL.startsWith(prefix), - ) - ) { - return false; - } - const isHttp = normalizedURL.startsWith(rendererBaseURL); - const isWs = normalizedURL.startsWith( - rendererBaseURL.replace(/^http:/, 'ws:'), - ); - return !isHttp && !isWs; -} - async function createWindow(): Promise { mainWindow = new BrowserWindow({ show: false, @@ -169,29 +143,7 @@ async function createWindow(): Promise { webContents.userAgent = originalUserAgent; - webContents.session.setPermissionRequestHandler( - (_webContents, _permission, callback) => { - callback(false); - }, - ); - - webContents.session.webRequest.onBeforeRequest( - ({ url, method }, callback) => { - callback({ - cancel: shouldCancelMainWindowRequest(url, method), - }); - }, - ); - - const pageURL = resources.getRendererURL('index.html'); - - webContents.on('will-navigate', (event, url) => { - if (url !== pageURL) { - event.preventDefault(); - } - }); - - webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + hardenSession(resources, isDevelopment, webContents.session); if (isDevelopment) { openDevToolsWhenReady(mainWindow); @@ -211,6 +163,7 @@ async function createWindow(): Promise { }); browserView.webContents.userAgent = userAgent; + autorun(() => { browserView.setBounds(store.browserViewBounds); }); @@ -350,7 +303,7 @@ async function createWindow(): Promise { log.error('Failed to load browser', error); }); - return mainWindow.loadURL(pageURL); + return lockWebContentsToFile(resources, 'index.html', webContents); } app.on('second-instance', () => { diff --git a/packages/main/src/infrastructure/electron/impl/hardenSession.ts b/packages/main/src/infrastructure/electron/impl/hardenSession.ts new file mode 100644 index 0000000..71d8148 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/hardenSession.ts @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { URL } from 'node:url'; + +import type { Session } from 'electron'; + +import { getLogger } from '../../../utils/log'; +import type Resources from '../../resources/Resources'; + +import { DEVMODE_ALLOWED_URL_PREFIXES } from './devTools'; + +const log = getLogger('hardenSession'); + +/** + * Hardens a session to prevent loading resources outside the renderer resources and + * to reject all permission requests. + * + * In dev mode, installation of extensions and opening the devtools will be allowed. + * + * @param resources The resource handle associated with the paths and URL of the application. + * @param devMode Whether the application is in development mode. + * @param session The session to harden. + */ +export default function hardenSession( + resources: Resources, + devMode: boolean, + session: Session, +): void { + session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); + + const rendererBaseURL = resources.getRendererURL('/'); + log.debug('Renderer base URL:', rendererBaseURL); + + const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); + log.debug('WebSocket base URL:', webSocketBaseURL); + + function shouldCancelRequest(url: string, method: string): boolean { + if (method !== 'GET') { + return true; + } + let normalizedURL: string; + try { + normalizedURL = new URL(url).toString(); + } catch { + return true; + } + if ( + devMode && + DEVMODE_ALLOWED_URL_PREFIXES.some((prefix) => + normalizedURL.startsWith(prefix), + ) + ) { + return false; + } + const isHttp = normalizedURL.startsWith(rendererBaseURL); + const isWs = normalizedURL.startsWith(webSocketBaseURL); + return !isHttp && !isWs; + } + + session.webRequest.onBeforeRequest(({ url, method }, callback) => { + const cancel = shouldCancelRequest(url, method); + if (cancel) { + log.error('Prevented loading', method, url); + } + callback({ cancel }); + }); +} diff --git a/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts new file mode 100644 index 0000000..6b458e0 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 Kristóf Marussy + * + * This file is part of Sophie. + * + * Sophie is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { WebContents } from 'electron'; + +import { getLogger } from '../../../utils/log'; +import type Resources from '../../resources/Resources'; + +const log = getLogger('lockWebContentsToFile'); + +/** + * Loads the specified file in the webContents and prevent navigating away. + * + * Both navigating away to a different URL and opening a new window will be disallowed. + * + * @param resources The resource handle associated with the paths and URL of the application. + * @param filePath The path to the file in the render package to load. + * @param webContents The webContents to lock. + */ +export default function lockWebContentsToFile( + resources: Resources, + filePath: string, + webContents: WebContents, +): Promise { + const pageURL = resources.getRendererURL(filePath); + + webContents.setWindowOpenHandler(() => ({ action: 'deny' })); + + webContents.on('will-navigate', (event, url) => { + if (url !== pageURL) { + log.error( + 'Prevented webContents locked to', + pageURL, + 'from navigating to', + url, + ); + event.preventDefault(); + } + }); + + return webContents.loadURL(pageURL); +} -- cgit v1.2.3-54-g00ecf