aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main/src
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-01-27 23:48:07 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-02-08 21:43:18 +0100
commit785826a05e99c98aae872b909a833a691e4ae9fa (patch)
treed3c0099516852f312973967c621ac82f4d6b1d41 /packages/main/src
parentbuild: Add test-utils package (diff)
downloadsophie-785826a05e99c98aae872b909a833a691e4ae9fa.tar.gz
sophie-785826a05e99c98aae872b909a833a691e4ae9fa.tar.zst
sophie-785826a05e99c98aae872b909a833a691e4ae9fa.zip
test: Add tests for main window hardening
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 <kristof@marussy.com>
Diffstat (limited to 'packages/main/src')
-rw-r--r--packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts168
-rw-r--r--packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts114
-rw-r--r--packages/main/src/infrastructure/electron/impl/hardenSession.ts20
-rw-r--r--packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts2
-rw-r--r--packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts20
5 files changed, 300 insertions, 24 deletions
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 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { URL } from 'node:url';
22
23import { jest } from '@jest/globals';
24import { each, fake } from '@sophie/test-utils';
25import type {
26 OnBeforeRequestListenerDetails,
27 PermissionRequestHandlerHandlerDetails,
28 Response,
29 Session,
30 WebContents,
31} from 'electron';
32
33import { silenceLogger } from '../../../../utils/log';
34import type Resources from '../../../resources/Resources';
35import hardenSession from '../hardenSession';
36
37const permissions = [
38 'clipboard-read',
39 'media',
40 'display-capture',
41 'mediaKeySystem',
42 'geolocation',
43 'notifications',
44 'midi',
45 'midiSysex',
46 'pointerLock',
47 'fullscreen',
48 'openExternal',
49 'unknown',
50] as const;
51
52type Permission = typeof permissions[number];
53
54let permissionRequestHandler:
55 | ((
56 webContents: WebContents,
57 permission: Permission,
58 callback: (permissionGranted: boolean) => void,
59 details: PermissionRequestHandlerHandlerDetails,
60 ) => void)
61 | undefined;
62
63let onBeforeRequest:
64 | ((
65 details: OnBeforeRequestListenerDetails,
66 callback: (response: Response) => void,
67 ) => void)
68 | undefined;
69
70function getFakeResources(devMode: boolean) {
71 const resourcesUrl = devMode
72 ? 'http://localhost:3000/'
73 : 'file:///opt/sophie/resources/app.asar/renderer/dist/';
74 return fake<Resources>({
75 getRendererURL(path: string) {
76 return new URL(path, resourcesUrl).toString();
77 },
78 });
79}
80
81const fakeSession = fake<Session>({
82 setPermissionRequestHandler(handler) {
83 permissionRequestHandler =
84 handler instanceof Function ? handler : undefined;
85 },
86 webRequest: {
87 onBeforeRequest(handler) {
88 onBeforeRequest = handler instanceof Function ? handler : undefined;
89 },
90 },
91});
92
93beforeAll(() => {
94 silenceLogger();
95});
96
97beforeEach(() => {
98 permissionRequestHandler = undefined;
99 onBeforeRequest = undefined;
100});
101
102it('should set permission request and before request handlers', () => {
103 hardenSession(getFakeResources(false), false, fakeSession);
104 expect(permissionRequestHandler).toBeDefined();
105 expect(onBeforeRequest).toBeDefined();
106});
107
108each(permissions.map((permission) => [permission])).it(
109 'should reject %s permission requests',
110 (permission: Permission) => {
111 hardenSession(getFakeResources(false), false, fakeSession);
112 const callback = jest.fn();
113 permissionRequestHandler!(fake<WebContents>({}), permission, callback, {
114 requestingUrl:
115 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html',
116 isMainFrame: true,
117 });
118 expect(callback).toHaveBeenCalledWith(false);
119 },
120);
121
122each([
123 [
124 false,
125 'GET',
126 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html',
127 false,
128 ],
129 [
130 false,
131 'POST',
132 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html',
133 true,
134 ],
135 [false, 'GET', 'chrome-extension:aaaa', true],
136 [false, 'GET', 'devtools:aaaa', true],
137 [false, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', true],
138 [false, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', true],
139 [false, 'GET', 'https://example.com', true],
140 [false, 'GET', 'invalid-url', true],
141 [true, 'GET', 'http://localhost:3000/index.html', false],
142 [true, 'POST', 'http://localhost:3000/index.html', true],
143 [true, 'GET', 'ws://localhost:3000/index.html', false],
144 [true, 'GET', 'chrome-extension:aaaa', false],
145 [true, 'GET', 'devtools:aaaa', false],
146 [true, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', false],
147 [true, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', false],
148 [true, 'GET', 'https://example.com', true],
149]).it(
150 'in dev mode: %s the request %s %s should be cancelled: %s',
151 (devMode: boolean, method: string, url: string, cancel: boolean) => {
152 hardenSession(getFakeResources(devMode), devMode, fakeSession);
153 const callback = jest.fn();
154 onBeforeRequest!(
155 {
156 id: 0,
157 url,
158 method,
159 resourceType: 'mainFrame',
160 referrer: '',
161 timestamp: 0,
162 uploadData: [],
163 },
164 callback,
165 );
166 expect(callback).toHaveBeenCalledWith(expect.objectContaining({ cancel }));
167 },
168);
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 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { URL } from 'node:url';
22
23import { jest } from '@jest/globals';
24import { fake } from '@sophie/test-utils';
25import type { Event, HandlerDetails, WebContents } from 'electron';
26import { mocked } from 'jest-mock';
27
28import { silenceLogger } from '../../../../utils/log';
29import type Resources from '../../../resources/Resources';
30import lockWebContentsToFile from '../lockWebContentsToFile';
31
32type WillNavigateHandler = (event: Event, url: string) => void;
33
34let willNavigate: WillNavigateHandler | undefined;
35
36let windowOpenHandler:
37 | ((details: HandlerDetails) => { action: 'allow' | 'deny' })
38 | undefined;
39
40const urlToLoad =
41 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html';
42
43const fakeResources = fake<Resources>({
44 getRendererURL(path: string) {
45 return new URL(
46 path,
47 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/',
48 ).toString();
49 },
50});
51
52const fakeWebContents = fake<WebContents>({
53 setWindowOpenHandler(handler) {
54 windowOpenHandler = handler;
55 },
56 on(event, listener) {
57 if (event === 'will-navigate') {
58 willNavigate = listener as WillNavigateHandler;
59 }
60 return this as WebContents;
61 },
62 loadURL: jest.fn(),
63});
64
65const event: Event = {
66 preventDefault: jest.fn(),
67};
68
69beforeAll(() => {
70 silenceLogger();
71});
72
73beforeEach(async () => {
74 windowOpenHandler = undefined;
75 willNavigate = undefined;
76 mocked(fakeWebContents.loadURL).mockResolvedValueOnce();
77 await lockWebContentsToFile(fakeResources, 'index.html', fakeWebContents);
78});
79
80it('should load the specified file', () => {
81 expect(fakeWebContents.loadURL).toHaveBeenCalledWith(urlToLoad);
82});
83
84it('should set up will navigate and window open listeners', () => {
85 expect(willNavigate).toBeDefined();
86 expect(windowOpenHandler).toBeDefined();
87});
88
89it('should prevent opening a window', () => {
90 const { action } = windowOpenHandler!({
91 url: 'https://example.com',
92 frameName: 'newWindow',
93 features: '',
94 disposition: 'default',
95 referrer: {
96 url: urlToLoad,
97 policy: 'default',
98 },
99 });
100 expect(action).toBe('deny');
101});
102
103it('should allow navigation to the loaded URL', () => {
104 willNavigate!(event, urlToLoad);
105 expect(event.preventDefault).not.toHaveBeenCalled();
106});
107
108it('should not allow navigation to another URL', () => {
109 willNavigate!(
110 event,
111 'file:///opt/sophie/resources/app.asar/packages/renderer/not-allowed.html',
112 );
113 expect(event.preventDefault).toHaveBeenCalled();
114});
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(
51 const rendererBaseURL = resources.getRendererURL('/'); 51 const rendererBaseURL = resources.getRendererURL('/');
52 log.debug('Renderer base URL:', rendererBaseURL); 52 log.debug('Renderer base URL:', rendererBaseURL);
53 53
54 const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); 54 const allowedPrefixes = [rendererBaseURL];
55 log.debug('WebSocket base URL:', webSocketBaseURL); 55 if (devMode) {
56 const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:');
57 log.debug('WebSocket base URL:', webSocketBaseURL);
58 allowedPrefixes.push(webSocketBaseURL, ...DEVMODE_ALLOWED_URL_PREFIXES);
59 }
56 60
57 function shouldCancelRequest(url: string, method: string): boolean { 61 function shouldCancelRequest(url: string, method: string): boolean {
58 if (method !== 'GET') { 62 if (method !== 'GET') {
@@ -64,17 +68,7 @@ export default function hardenSession(
64 } catch { 68 } catch {
65 return true; 69 return true;
66 } 70 }
67 if ( 71 return !allowedPrefixes.some((prefix) => normalizedURL.startsWith(prefix));
68 devMode &&
69 DEVMODE_ALLOWED_URL_PREFIXES.some((prefix) =>
70 normalizedURL.startsWith(prefix),
71 )
72 ) {
73 return false;
74 }
75 const isHttp = normalizedURL.startsWith(rendererBaseURL);
76 const isWs = normalizedURL.startsWith(webSocketBaseURL);
77 return !isHttp && !isWs;
78 } 72 }
79 73
80 session.webRequest.onBeforeRequest(({ url, method }, callback) => { 74 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 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { WebContents } from 'electron'; 21import type { WebContents } from 'electron';
22 22
23import { getLogger } from '../../../utils/log'; 23import { getLogger } from '../../../utils/log';
24import type Resources from '../../resources/Resources'; 24import type Resources from '../../resources/Resources';
diff --git a/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts b/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts
index e7e9d71..635a6b4 100644
--- a/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts
+++ b/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts
@@ -36,18 +36,18 @@ const [
36] = 36] =
37 os.platform() === 'win32' 37 os.platform() === 'win32'
38 ? [ 38 ? [
39 'C:\\Program Files\\sophie\\resources\\app.asar\\main\\dist', 39 'C:\\Program Files\\sophie\\resources\\app.asar\\packages\\main\\dist',
40 'C:\\Program Files\\sophie\\resources\\app.asar\\preload\\dist\\index.cjs', 40 'C:\\Program Files\\sophie\\resources\\app.asar\\packages\\preload\\dist\\index.cjs',
41 'file:///C:/Program Files/sophie/resources/app.asar/preload/dist/index.cjs', 41 'file:///C:/Program Files/sophie/resources/app.asar/packages/preload/dist/index.cjs',
42 'file:///C:/Program Files/sophie/resources/app.asar/renderer/dist/index.html', 42 'file:///C:/Program Files/sophie/resources/app.asar/packages/renderer/dist/index.html',
43 'file:///C:/Program Files/sophie/resources/app.asar/renderer/dist/', 43 'file:///C:/Program Files/sophie/resources/app.asar/packages/renderer/dist/',
44 ] 44 ]
45 : [ 45 : [
46 '/opt/sophie/resources/app.asar/main/dist', 46 '/opt/sophie/resources/app.asar/packages/main/dist',
47 '/opt/sophie/resources/app.asar/preload/dist/index.cjs', 47 '/opt/sophie/resources/app.asar/packages/preload/dist/index.cjs',
48 'file:///opt/sophie/resources/app.asar/preload/dist/index.cjs', 48 'file:///opt/sophie/resources/app.asar/packages/preload/dist/index.cjs',
49 'file:///opt/sophie/resources/app.asar/renderer/dist/index.html', 49 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html',
50 'file:///opt/sophie/resources/app.asar/renderer/dist/', 50 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/',
51 ]; 51 ];
52 52
53const fileURLs: [string, string] = [rendererIndexFileURL, rendererRootFileURL]; 53const fileURLs: [string, string] = [rendererIndexFileURL, rendererRootFileURL];