From 785826a05e99c98aae872b909a833a691e4ae9fa Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Thu, 27 Jan 2022 23:48:07 +0100 Subject: test: Add tests for main window hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We try to stub/mock the Electron API to make sure the test environment is as close to the runtime environment for this security critical code. Signed-off-by: Kristóf Marussy --- .../electron/impl/__tests__/hardenSession.spec.ts | 168 +++++++++++++++++++++ .../impl/__tests__/lockWebContentsToFile.spec.ts | 114 ++++++++++++++ .../infrastructure/electron/impl/hardenSession.ts | 20 +-- .../electron/impl/lockWebContentsToFile.ts | 2 +- 4 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts create mode 100644 packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts (limited to 'packages/main/src/infrastructure/electron/impl') diff --git a/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts b/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts new file mode 100644 index 0000000..7457729 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts @@ -0,0 +1,168 @@ +/* + * 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 { jest } from '@jest/globals'; +import { each, fake } from '@sophie/test-utils'; +import type { + OnBeforeRequestListenerDetails, + PermissionRequestHandlerHandlerDetails, + Response, + Session, + WebContents, +} from 'electron'; + +import { silenceLogger } from '../../../../utils/log'; +import type Resources from '../../../resources/Resources'; +import hardenSession from '../hardenSession'; + +const permissions = [ + 'clipboard-read', + 'media', + 'display-capture', + 'mediaKeySystem', + 'geolocation', + 'notifications', + 'midi', + 'midiSysex', + 'pointerLock', + 'fullscreen', + 'openExternal', + 'unknown', +] as const; + +type Permission = typeof permissions[number]; + +let permissionRequestHandler: + | (( + webContents: WebContents, + permission: Permission, + callback: (permissionGranted: boolean) => void, + details: PermissionRequestHandlerHandlerDetails, + ) => void) + | undefined; + +let onBeforeRequest: + | (( + details: OnBeforeRequestListenerDetails, + callback: (response: Response) => void, + ) => void) + | undefined; + +function getFakeResources(devMode: boolean) { + const resourcesUrl = devMode + ? 'http://localhost:3000/' + : 'file:///opt/sophie/resources/app.asar/renderer/dist/'; + return fake({ + getRendererURL(path: string) { + return new URL(path, resourcesUrl).toString(); + }, + }); +} + +const fakeSession = fake({ + setPermissionRequestHandler(handler) { + permissionRequestHandler = + handler instanceof Function ? handler : undefined; + }, + webRequest: { + onBeforeRequest(handler) { + onBeforeRequest = handler instanceof Function ? handler : undefined; + }, + }, +}); + +beforeAll(() => { + silenceLogger(); +}); + +beforeEach(() => { + permissionRequestHandler = undefined; + onBeforeRequest = undefined; +}); + +it('should set permission request and before request handlers', () => { + hardenSession(getFakeResources(false), false, fakeSession); + expect(permissionRequestHandler).toBeDefined(); + expect(onBeforeRequest).toBeDefined(); +}); + +each(permissions.map((permission) => [permission])).it( + 'should reject %s permission requests', + (permission: Permission) => { + hardenSession(getFakeResources(false), false, fakeSession); + const callback = jest.fn(); + permissionRequestHandler!(fake({}), permission, callback, { + requestingUrl: + 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', + isMainFrame: true, + }); + expect(callback).toHaveBeenCalledWith(false); + }, +); + +each([ + [ + false, + 'GET', + 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', + false, + ], + [ + false, + 'POST', + 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', + true, + ], + [false, 'GET', 'chrome-extension:aaaa', true], + [false, 'GET', 'devtools:aaaa', true], + [false, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', true], + [false, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', true], + [false, 'GET', 'https://example.com', true], + [false, 'GET', 'invalid-url', true], + [true, 'GET', 'http://localhost:3000/index.html', false], + [true, 'POST', 'http://localhost:3000/index.html', true], + [true, 'GET', 'ws://localhost:3000/index.html', false], + [true, 'GET', 'chrome-extension:aaaa', false], + [true, 'GET', 'devtools:aaaa', false], + [true, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', false], + [true, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', false], + [true, 'GET', 'https://example.com', true], +]).it( + 'in dev mode: %s the request %s %s should be cancelled: %s', + (devMode: boolean, method: string, url: string, cancel: boolean) => { + hardenSession(getFakeResources(devMode), devMode, fakeSession); + const callback = jest.fn(); + onBeforeRequest!( + { + id: 0, + url, + method, + resourceType: 'mainFrame', + referrer: '', + timestamp: 0, + uploadData: [], + }, + callback, + ); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ cancel })); + }, +); diff --git a/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts b/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts new file mode 100644 index 0000000..29c0516 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts @@ -0,0 +1,114 @@ +/* + * 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 { jest } from '@jest/globals'; +import { fake } from '@sophie/test-utils'; +import type { Event, HandlerDetails, WebContents } from 'electron'; +import { mocked } from 'jest-mock'; + +import { silenceLogger } from '../../../../utils/log'; +import type Resources from '../../../resources/Resources'; +import lockWebContentsToFile from '../lockWebContentsToFile'; + +type WillNavigateHandler = (event: Event, url: string) => void; + +let willNavigate: WillNavigateHandler | undefined; + +let windowOpenHandler: + | ((details: HandlerDetails) => { action: 'allow' | 'deny' }) + | undefined; + +const urlToLoad = + 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html'; + +const fakeResources = fake({ + getRendererURL(path: string) { + return new URL( + path, + 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/', + ).toString(); + }, +}); + +const fakeWebContents = fake({ + setWindowOpenHandler(handler) { + windowOpenHandler = handler; + }, + on(event, listener) { + if (event === 'will-navigate') { + willNavigate = listener as WillNavigateHandler; + } + return this as WebContents; + }, + loadURL: jest.fn(), +}); + +const event: Event = { + preventDefault: jest.fn(), +}; + +beforeAll(() => { + silenceLogger(); +}); + +beforeEach(async () => { + windowOpenHandler = undefined; + willNavigate = undefined; + mocked(fakeWebContents.loadURL).mockResolvedValueOnce(); + await lockWebContentsToFile(fakeResources, 'index.html', fakeWebContents); +}); + +it('should load the specified file', () => { + expect(fakeWebContents.loadURL).toHaveBeenCalledWith(urlToLoad); +}); + +it('should set up will navigate and window open listeners', () => { + expect(willNavigate).toBeDefined(); + expect(windowOpenHandler).toBeDefined(); +}); + +it('should prevent opening a window', () => { + const { action } = windowOpenHandler!({ + url: 'https://example.com', + frameName: 'newWindow', + features: '', + disposition: 'default', + referrer: { + url: urlToLoad, + policy: 'default', + }, + }); + expect(action).toBe('deny'); +}); + +it('should allow navigation to the loaded URL', () => { + willNavigate!(event, urlToLoad); + expect(event.preventDefault).not.toHaveBeenCalled(); +}); + +it('should not allow navigation to another URL', () => { + willNavigate!( + event, + 'file:///opt/sophie/resources/app.asar/packages/renderer/not-allowed.html', + ); + expect(event.preventDefault).toHaveBeenCalled(); +}); diff --git a/packages/main/src/infrastructure/electron/impl/hardenSession.ts b/packages/main/src/infrastructure/electron/impl/hardenSession.ts index 71d8148..10b694a 100644 --- a/packages/main/src/infrastructure/electron/impl/hardenSession.ts +++ b/packages/main/src/infrastructure/electron/impl/hardenSession.ts @@ -51,8 +51,12 @@ export default function hardenSession( const rendererBaseURL = resources.getRendererURL('/'); log.debug('Renderer base URL:', rendererBaseURL); - const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); - log.debug('WebSocket base URL:', webSocketBaseURL); + const allowedPrefixes = [rendererBaseURL]; + if (devMode) { + const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); + log.debug('WebSocket base URL:', webSocketBaseURL); + allowedPrefixes.push(webSocketBaseURL, ...DEVMODE_ALLOWED_URL_PREFIXES); + } function shouldCancelRequest(url: string, method: string): boolean { if (method !== 'GET') { @@ -64,17 +68,7 @@ export default function hardenSession( } 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; + return !allowedPrefixes.some((prefix) => normalizedURL.startsWith(prefix)); } session.webRequest.onBeforeRequest(({ url, method }, callback) => { diff --git a/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts index 6b458e0..48b1bf0 100644 --- a/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts +++ b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts @@ -18,7 +18,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { WebContents } from 'electron'; +import type { WebContents } from 'electron'; import { getLogger } from '../../../utils/log'; import type Resources from '../../resources/Resources'; -- cgit v1.2.3-70-g09d2