diff options
Diffstat (limited to 'packages/main/src/infrastructure/electron/impl')
4 files changed, 290 insertions, 14 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 | |||
21 | import { URL } from 'node:url'; | ||
22 | |||
23 | import { jest } from '@jest/globals'; | ||
24 | import { each, fake } from '@sophie/test-utils'; | ||
25 | import type { | ||
26 | OnBeforeRequestListenerDetails, | ||
27 | PermissionRequestHandlerHandlerDetails, | ||
28 | Response, | ||
29 | Session, | ||
30 | WebContents, | ||
31 | } from 'electron'; | ||
32 | |||
33 | import { silenceLogger } from '../../../../utils/log'; | ||
34 | import type Resources from '../../../resources/Resources'; | ||
35 | import hardenSession from '../hardenSession'; | ||
36 | |||
37 | const 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 | |||
52 | type Permission = typeof permissions[number]; | ||
53 | |||
54 | let permissionRequestHandler: | ||
55 | | (( | ||
56 | webContents: WebContents, | ||
57 | permission: Permission, | ||
58 | callback: (permissionGranted: boolean) => void, | ||
59 | details: PermissionRequestHandlerHandlerDetails, | ||
60 | ) => void) | ||
61 | | undefined; | ||
62 | |||
63 | let onBeforeRequest: | ||
64 | | (( | ||
65 | details: OnBeforeRequestListenerDetails, | ||
66 | callback: (response: Response) => void, | ||
67 | ) => void) | ||
68 | | undefined; | ||
69 | |||
70 | function 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 | |||
81 | const 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 | |||
93 | beforeAll(() => { | ||
94 | silenceLogger(); | ||
95 | }); | ||
96 | |||
97 | beforeEach(() => { | ||
98 | permissionRequestHandler = undefined; | ||
99 | onBeforeRequest = undefined; | ||
100 | }); | ||
101 | |||
102 | it('should set permission request and before request handlers', () => { | ||
103 | hardenSession(getFakeResources(false), false, fakeSession); | ||
104 | expect(permissionRequestHandler).toBeDefined(); | ||
105 | expect(onBeforeRequest).toBeDefined(); | ||
106 | }); | ||
107 | |||
108 | each(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 | |||
122 | each([ | ||
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 | |||
21 | import { URL } from 'node:url'; | ||
22 | |||
23 | import { jest } from '@jest/globals'; | ||
24 | import { fake } from '@sophie/test-utils'; | ||
25 | import type { Event, HandlerDetails, WebContents } from 'electron'; | ||
26 | import { mocked } from 'jest-mock'; | ||
27 | |||
28 | import { silenceLogger } from '../../../../utils/log'; | ||
29 | import type Resources from '../../../resources/Resources'; | ||
30 | import lockWebContentsToFile from '../lockWebContentsToFile'; | ||
31 | |||
32 | type WillNavigateHandler = (event: Event, url: string) => void; | ||
33 | |||
34 | let willNavigate: WillNavigateHandler | undefined; | ||
35 | |||
36 | let windowOpenHandler: | ||
37 | | ((details: HandlerDetails) => { action: 'allow' | 'deny' }) | ||
38 | | undefined; | ||
39 | |||
40 | const urlToLoad = | ||
41 | 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html'; | ||
42 | |||
43 | const 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 | |||
52 | const 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 | |||
65 | const event: Event = { | ||
66 | preventDefault: jest.fn(), | ||
67 | }; | ||
68 | |||
69 | beforeAll(() => { | ||
70 | silenceLogger(); | ||
71 | }); | ||
72 | |||
73 | beforeEach(async () => { | ||
74 | windowOpenHandler = undefined; | ||
75 | willNavigate = undefined; | ||
76 | mocked(fakeWebContents.loadURL).mockResolvedValueOnce(); | ||
77 | await lockWebContentsToFile(fakeResources, 'index.html', fakeWebContents); | ||
78 | }); | ||
79 | |||
80 | it('should load the specified file', () => { | ||
81 | expect(fakeWebContents.loadURL).toHaveBeenCalledWith(urlToLoad); | ||
82 | }); | ||
83 | |||
84 | it('should set up will navigate and window open listeners', () => { | ||
85 | expect(willNavigate).toBeDefined(); | ||
86 | expect(windowOpenHandler).toBeDefined(); | ||
87 | }); | ||
88 | |||
89 | it('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 | |||
103 | it('should allow navigation to the loaded URL', () => { | ||
104 | willNavigate!(event, urlToLoad); | ||
105 | expect(event.preventDefault).not.toHaveBeenCalled(); | ||
106 | }); | ||
107 | |||
108 | it('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 | ||
21 | import { WebContents } from 'electron'; | 21 | import type { WebContents } from 'electron'; |
22 | 22 | ||
23 | import { getLogger } from '../../../utils/log'; | 23 | import { getLogger } from '../../../utils/log'; |
24 | import type Resources from '../../resources/Resources'; | 24 | import type Resources from '../../resources/Resources'; |