aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--.eslintrc.cjs2
-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
-rw-r--r--packages/test-utils/package.json3
-rw-r--r--packages/test-utils/src/fake.ts34
-rw-r--r--packages/test-utils/src/index.ts3
-rw-r--r--yarn.lock8
10 files changed, 348 insertions, 26 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 55a055d..8f1451b 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -56,6 +56,8 @@ module.exports = {
56 'newlines-between': 'always', 56 'newlines-between': 'always',
57 }, 57 },
58 ], 58 ],
59 // jest-each confuses this rule.
60 'jest/no-standalone-expect': 'off',
59 // Allows files with names same as the name of their default export. 61 // Allows files with names same as the name of their default export.
60 'unicorn/filename-case': [ 62 'unicorn/filename-case': [
61 'error', 63 'error',
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];
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 9270af2..2f0610a 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -12,6 +12,7 @@
12 "dependencies": { 12 "dependencies": {
13 "@types/jest": "^27.4.0", 13 "@types/jest": "^27.4.0",
14 "jest": "^27.4.7", 14 "jest": "^27.4.7",
15 "jest-each": "^27.4.6" 15 "jest-each": "^27.4.6",
16 "type-fest": "^2.11.0"
16 } 17 }
17} 18}
diff --git a/packages/test-utils/src/fake.ts b/packages/test-utils/src/fake.ts
new file mode 100644
index 0000000..57cfa88
--- /dev/null
+++ b/packages/test-utils/src/fake.ts
@@ -0,0 +1,34 @@
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 type { PartialDeep } from 'type-fest';
22
23/**
24 * Creates a fake object with some properties possibly missing.
25 *
26 * Interacting with missing properties will cause a test failure due to
27 * trying to access members of `undefined.`
28 *
29 * @param partialImplementation The partial fake implementation.
30 * @returns The same value as `partialImplementation` but with a casted type.
31 */
32export default function fake<T>(partialImplementation: PartialDeep<T>): T {
33 return partialImplementation as T;
34}
diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts
index 5de84f1..1543c6e 100644
--- a/packages/test-utils/src/index.ts
+++ b/packages/test-utils/src/index.ts
@@ -18,5 +18,6 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21// eslint-disable-next-line import/prefer-default-export -- More exports will be added here.
22export { default as each } from './each'; 21export { default as each } from './each';
22
23export { default as fake } from './fake';
diff --git a/yarn.lock b/yarn.lock
index 85f7b61..8a165aa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1360,6 +1360,7 @@ __metadata:
1360 "@types/jest": ^27.4.0 1360 "@types/jest": ^27.4.0
1361 jest: ^27.4.7 1361 jest: ^27.4.7
1362 jest-each: ^27.4.6 1362 jest-each: ^27.4.6
1363 type-fest: ^2.11.0
1363 languageName: unknown 1364 languageName: unknown
1364 linkType: soft 1365 linkType: soft
1365 1366
@@ -8984,6 +8985,13 @@ __metadata:
8984 languageName: node 8985 languageName: node
8985 linkType: hard 8986 linkType: hard
8986 8987
8988"type-fest@npm:^2.11.0":
8989 version: 2.11.0
8990 resolution: "type-fest@npm:2.11.0"
8991 checksum: 267d8d6d36a87d72a050354b79daf644daed24c9880979bb688d7636dca5afc23dfba616dd0fbccfbbb77cc6511dc312e758cea08ab54160d09fda5ca0ef2eed
8992 languageName: node
8993 linkType: hard
8994
8987"typedarray-to-buffer@npm:^3.1.5": 8995"typedarray-to-buffer@npm:^3.1.5":
8988 version: 3.1.5 8996 version: 3.1.5
8989 resolution: "typedarray-to-buffer@npm:3.1.5" 8997 resolution: "typedarray-to-buffer@npm:3.1.5"