aboutsummaryrefslogtreecommitdiffstats
path: root/src/webview
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-07-24 02:23:48 +0200
committerLibravatar GitHub <noreply@github.com>2021-07-24 02:23:48 +0200
commit9c3c441941ad5060ec2db89b805a958a914547f3 (patch)
treea4224d7bd2576797b14a2ffa1d25ba6e167351ae /src/webview
parentUpdate submodules, browserslist data updates and linter fixes [skip ci] (diff)
downloadferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.tar.gz
ferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.tar.zst
ferdium-app-9c3c441941ad5060ec2db89b805a958a914547f3.zip
Recipe context isolation (#1456)
* Enable service contextIsolation * Enable contextIsolation on the service webviews * Expose a new API window.ferdi in the service main world to allow calling back into the service isolated world * Expose a new IPC message inject-js-unsafe from the service isolated world to execute Javascript in the service main world (i.e., run code without context isolation). While the name contains the "unsafe" suffix to show the lack of context isolation, this should mostly be safe, as no nodejs APIs are available in the injected code. * Refactor the Notifications shim into a part in the isolated world that handles displaying and modifying notifications, and a shim in the main world for the Notifications class. The two communicate via the window.ferdi endpoint and a Promise object can be used to detect notification clicks. * Refactor the screen sharing shim into a part in the isolated world that enumerated shareable screens and windows and a shim in the main world that displays the media selector and completes the media selection promise. * Expose the injectJSUnsafe API to recipes to inject javascript code into the main world without context isolation. * Expose setBadge to the main world The window.ferdi.setBadge API can be used to update the service badge from injected unsafe Javascript * Safer script injection into the service main world Make sure that we don't try to serialize stray objects back from the main world to the isolated world by always surrounding the script to be executed by an anonymous function. * Always read recipe assets as utf8 * Remove window.log from recipes We didn't use it anywhere and its behavior was confusing in production mode. * Inject multiple unsafe scripts at the same time * Find in page without remote module Remove the @electron/remote dependency from the find in page (Ctrl+F) functionality. The remote webContents is replaced with Electron IPC. Synchronous IPC messages are handled in the main Electron process, because the renderer process cannot reply to IPC messages synchronously. * Update to latest contextIsolation recipes * Fixing issue with missing 'fs' functions. Co-authored-by: Vijay A <avijayr@protonmail.com>
Diffstat (limited to 'src/webview')
-rw-r--r--src/webview/badge.js33
-rw-r--r--src/webview/find.js23
-rw-r--r--src/webview/lib/RecipeWebview.js48
-rw-r--r--src/webview/notifications.js88
-rw-r--r--src/webview/recipe.js132
-rw-r--r--src/webview/screenshare.js81
6 files changed, 239 insertions, 166 deletions
diff --git a/src/webview/badge.js b/src/webview/badge.js
new file mode 100644
index 000000000..1e02fb56a
--- /dev/null
+++ b/src/webview/badge.js
@@ -0,0 +1,33 @@
1const { ipcRenderer } = require('electron');
2
3const debug = require('debug')('Ferdi:Plugin:BadgeHandler');
4
5export class BadgeHandler {
6 constructor() {
7 this.countCache = {
8 direct: 0,
9 indirect: 0,
10 };
11 }
12
13 setBadge(direct, indirect) {
14 if (this.countCache.direct === direct
15 && this.countCache.indirect === indirect) return;
16
17 // Parse number to integer
18 // This will correct errors that recipes may introduce, e.g.
19 // by sending a String instead of an integer
20 const directInt = parseInt(direct, 10);
21 const indirectInt = parseInt(indirect, 10);
22
23 const count = {
24 direct: Math.max(directInt, 0),
25 indirect: Math.max(indirectInt, 0),
26 };
27
28 ipcRenderer.sendToHost('message-counts', count);
29 Object.assign(this.countCache, count);
30
31 debug('Sending badge count to host', count);
32 }
33}
diff --git a/src/webview/find.js b/src/webview/find.js
new file mode 100644
index 000000000..040811d68
--- /dev/null
+++ b/src/webview/find.js
@@ -0,0 +1,23 @@
1import { ipcRenderer } from 'electron';
2import { FindInPage as ElectronFindInPage } from 'electron-find';
3
4// Shim to expose webContents functionality to electron-find without @electron/remote
5const webContentsShim = {
6 findInPage: (text, options = {}) => ipcRenderer.sendSync('find-in-page', text, options),
7 stopFindInPage: (action) => {
8 ipcRenderer.sendSync('stop-find-in-page', action);
9 },
10 on: (eventName, listener) => {
11 if (eventName === 'found-in-page') {
12 ipcRenderer.on('found-in-page', (_, result) => {
13 listener({ sender: this }, result);
14 });
15 }
16 },
17};
18
19export default class FindInPage extends ElectronFindInPage {
20 constructor(options = {}) {
21 super(webContentsShim, options);
22 }
23}
diff --git a/src/webview/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js
index b8fe7dc52..3bb9352f6 100644
--- a/src/webview/lib/RecipeWebview.js
+++ b/src/webview/lib/RecipeWebview.js
@@ -1,14 +1,12 @@
1import { ipcRenderer } from 'electron'; 1import { ipcRenderer } from 'electron';
2import { pathExistsSync, readFile } from 'fs-extra'; 2import { exists, pathExistsSync, readFile } from 'fs-extra';
3 3
4const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); 4const debug = require('debug')('Ferdi:Plugin:RecipeWebview');
5 5
6class RecipeWebview { 6class RecipeWebview {
7 constructor() { 7 constructor(badgeHandler, notificationsHandler) {
8 this.countCache = { 8 this.badgeHandler = badgeHandler;
9 direct: 0, 9 this.notificationsHandler = notificationsHandler;
10 indirect: 0,
11 };
12 10
13 ipcRenderer.on('poll', () => { 11 ipcRenderer.on('poll', () => {
14 this.loopFunc(); 12 this.loopFunc();
@@ -45,24 +43,7 @@ class RecipeWebview {
45 * me directly to me eg. in a channel 43 * me directly to me eg. in a channel
46 */ 44 */
47 setBadge(direct = 0, indirect = 0) { 45 setBadge(direct = 0, indirect = 0) {
48 if (this.countCache.direct === direct 46 this.badgeHandler.setBadge(direct, indirect);
49 && this.countCache.indirect === indirect) return;
50
51 // Parse number to integer
52 // This will correct errors that recipes may introduce, e.g.
53 // by sending a String instead of an integer
54 const directInt = parseInt(direct, 10);
55 const indirectInt = parseInt(indirect, 10);
56
57 const count = {
58 direct: Math.max(directInt, 0),
59 indirect: Math.max(indirectInt, 0),
60 };
61
62 ipcRenderer.sendToHost('message-counts', count);
63 Object.assign(this.countCache, count);
64
65 debug('Sending badge count to host', count);
66 } 47 }
67 48
68 /** 49 /**
@@ -85,6 +66,23 @@ class RecipeWebview {
85 }); 66 });
86 } 67 }
87 68
69 injectJSUnsafe(...files) {
70 Promise.all(files.map(async (file) => {
71 if (await exists(file)) {
72 const data = await readFile(file, 'utf8');
73 return data;
74 }
75 debug('Script not found', file);
76 return null;
77 })).then(async (scripts) => {
78 const scriptsFound = scripts.filter(script => script !== null);
79 if (scriptsFound.length > 0) {
80 debug('Inject scripts to main world', scriptsFound);
81 ipcRenderer.sendToHost('inject-js-unsafe', ...scriptsFound);
82 }
83 });
84 }
85
88 /** 86 /**
89 * Set a custom handler for turning on and off dark mode 87 * Set a custom handler for turning on and off dark mode
90 * 88 *
@@ -96,7 +94,7 @@ class RecipeWebview {
96 94
97 onNotify(fn) { 95 onNotify(fn) {
98 if (typeof fn === 'function') { 96 if (typeof fn === 'function') {
99 window.Notification.prototype.onNotify = fn; 97 this.notificationsHandler.onNotify = fn;
100 } 98 }
101 } 99 }
102 100
diff --git a/src/webview/notifications.js b/src/webview/notifications.js
index 021f05cc3..39a515143 100644
--- a/src/webview/notifications.js
+++ b/src/webview/notifications.js
@@ -3,49 +3,65 @@ import uuidV1 from 'uuid/v1';
3 3
4const debug = require('debug')('Ferdi:Notifications'); 4const debug = require('debug')('Ferdi:Notifications');
5 5
6class Notification { 6export class NotificationsHandler {
7 static permission = 'granted'; 7 onNotify = data => data;
8 8
9 constructor(title = '', options = {}) { 9 displayNotification(title, options) {
10 debug('New notification', title, options); 10 return new Promise((resolve) => {
11 this.title = title; 11 debug('New notification', title, options);
12 this.options = options; 12
13 this.notificationId = uuidV1(); 13 const notificationId = uuidV1();
14 14
15 ipcRenderer.sendToHost('notification', this.onNotify({ 15 ipcRenderer.sendToHost('notification', this.onNotify({
16 title: this.title, 16 title,
17 options: this.options, 17 options,
18 notificationId: this.notificationId, 18 notificationId,
19 })); 19 }));
20 20
21 ipcRenderer.once(`notification-onclick:${this.notificationId}`, () => { 21 ipcRenderer.once(`notification-onclick:${notificationId}`, () => {
22 if (typeof this.onclick === 'function') { 22 resolve();
23 this.onclick(); 23 });
24 }
25 }); 24 });
26 } 25 }
26}
27 27
28 static requestPermission(cb = null) { 28export const notificationsClassDefinition = `(() => {
29 if (!cb) { 29 class Notification {
30 return new Promise((resolve) => { 30 static permission = 'granted';
31 resolve(Notification.permission);
32 });
33 }
34 31
35 if (typeof (cb) === 'function') { 32 constructor(title = '', options = {}) {
36 return cb(Notification.permission); 33 this.title = title;
34 this.options = options;
35 window.ferdi.displayNotification(title, options)
36 .then(() => {
37 if (typeof (this.onClick) === 'function') {
38 this.onClick();
39 }
40 });
37 } 41 }
38 42
39 return Notification.permission; 43 static requestPermission(cb = null) {
40 } 44 if (!cb) {
45 return new Promise((resolve) => {
46 resolve(Notification.permission);
47 });
48 }
41 49
42 onNotify(data) { 50 if (typeof (cb) === 'function') {
43 return data; 51 return cb(Notification.permission);
44 } 52 }
45 53
46 onClick() {} 54 return Notification.permission;
55 }
47 56
48 close() {} 57 onNotify(data) {
49} 58 return data;
59 }
60
61 onClick() {}
62
63 close() {}
64 }
50 65
51window.Notification = Notification; 66 window.Notification = Notification;
67})();`;
diff --git a/src/webview/recipe.js b/src/webview/recipe.js
index 8da45864b..d143675dc 100644
--- a/src/webview/recipe.js
+++ b/src/webview/recipe.js
@@ -1,11 +1,9 @@
1/* eslint-disable import/first */ 1/* eslint-disable import/first */
2import { ipcRenderer } from 'electron'; 2import { contextBridge, ipcRenderer } from 'electron';
3import { getCurrentWebContents } from '@electron/remote';
4import path from 'path'; 3import path from 'path';
5import { autorun, computed, observable } from 'mobx'; 4import { autorun, computed, observable } from 'mobx';
6import fs from 'fs-extra'; 5import fs from 'fs-extra';
7import { debounce } from 'lodash'; 6import { debounce } from 'lodash';
8import { FindInPage } from 'electron-find';
9 7
10// For some services darkreader tries to use the chrome extension message API 8// For some services darkreader tries to use the chrome extension message API
11// This will cause the service to fail loading 9// This will cause the service to fail loading
@@ -23,16 +21,81 @@ import customDarkModeCss from './darkmode/custom';
23import RecipeWebview from './lib/RecipeWebview'; 21import RecipeWebview from './lib/RecipeWebview';
24import Userscript from './lib/Userscript'; 22import Userscript from './lib/Userscript';
25 23
26import { switchDict, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker'; 24import { BadgeHandler } from './badge';
27import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode';
28import contextMenu from './contextMenu'; 25import contextMenu from './contextMenu';
29import './notifications'; 26import { injectDarkModeStyle, isDarkModeStyleInjected, removeDarkModeStyle } from './darkmode';
30import { screenShareCss } from './screenshare'; 27import FindInPage from './find';
28import { NotificationsHandler, notificationsClassDefinition } from './notifications';
29import { getDisplayMediaSelector, screenShareCss, screenShareJs } from './screenshare';
30import { switchDict, getSpellcheckerLocaleByFuzzyIdentifier } from './spellchecker';
31 31
32import { DEFAULT_APP_SETTINGS, isDevMode } from '../environment'; 32import { DEFAULT_APP_SETTINGS } from '../environment';
33 33
34const debug = require('debug')('Ferdi:Plugin'); 34const debug = require('debug')('Ferdi:Plugin');
35 35
36const badgeHandler = new BadgeHandler();
37
38const notificationsHandler = new NotificationsHandler();
39
40// Patching window.open
41const originalWindowOpen = window.open;
42
43window.open = (url, frameName, features) => {
44 debug('window.open', url, frameName, features);
45 if (!url) {
46 // The service hasn't yet supplied a URL (as used in Skype).
47 // Return a new dummy window object and wait for the service to change the properties
48 const newWindow = {
49 location: {
50 href: '',
51 },
52 };
53
54 const checkInterval = setInterval(() => {
55 // Has the service changed the URL yet?
56 if (newWindow.location.href !== '') {
57 if (features) {
58 originalWindowOpen(newWindow.location.href, frameName, features);
59 } else {
60 // Open the new URL
61 ipcRenderer.sendToHost('new-window', newWindow.location.href);
62 }
63 clearInterval(checkInterval);
64 }
65 }, 0);
66
67 setTimeout(() => {
68 // Stop checking for location changes after 1 second
69 clearInterval(checkInterval);
70 }, 1000);
71
72 return newWindow;
73 }
74
75 // We need to differentiate if the link should be opened in a popup or in the systems default browser
76 if (!frameName && !features && typeof features !== 'string') {
77 return ipcRenderer.sendToHost('new-window', url);
78 }
79
80 if (url) {
81 return originalWindowOpen(url, frameName, features);
82 }
83};
84
85// We can't override APIs here, so we first expose functions via window.ferdi,
86// then overwrite the corresponding field of the window object by injected JS.
87contextBridge.exposeInMainWorld('ferdi', {
88 open: window.open,
89 setBadge: (direct, indirect) => badgeHandler.setBadge(direct || 0, indirect || 0),
90 displayNotification: (title, options) => notificationsHandler.displayNotification(title, options),
91 getDisplayMediaSelector,
92});
93
94ipcRenderer.sendToHost('inject-js-unsafe',
95 'window.open = window.ferdi.open;',
96 notificationsClassDefinition,
97 screenShareJs);
98
36class RecipeController { 99class RecipeController {
37 @observable settings = { 100 @observable settings = {
38 overrideSpellcheckerLanguage: false, 101 overrideSpellcheckerLanguage: false,
@@ -97,7 +160,7 @@ class RecipeController {
97 autorun(() => this.update()); 160 autorun(() => this.update());
98 161
99 document.addEventListener('DOMContentLoaded', () => { 162 document.addEventListener('DOMContentLoaded', () => {
100 this.findInPage = new FindInPage(getCurrentWebContents(), { 163 this.findInPage = new FindInPage({
101 inputFocusColor: '#CE9FFC', 164 inputFocusColor: '#CE9FFC',
102 textColor: '#212121', 165 textColor: '#212121',
103 }); 166 });
@@ -111,7 +174,7 @@ class RecipeController {
111 // Delete module from cache 174 // Delete module from cache
112 delete require.cache[require.resolve(modulePath)]; 175 delete require.cache[require.resolve(modulePath)];
113 try { 176 try {
114 this.recipe = new RecipeWebview(); 177 this.recipe = new RecipeWebview(badgeHandler, notificationsHandler);
115 // eslint-disable-next-line 178 // eslint-disable-next-line
116 require(modulePath)(this.recipe, {...config, recipe,}); 179 require(modulePath)(this.recipe, {...config, recipe,});
117 debug('Initialize Recipe', config, recipe); 180 debug('Initialize Recipe', config, recipe);
@@ -327,52 +390,3 @@ class RecipeController {
327/* eslint-disable no-new */ 390/* eslint-disable no-new */
328new RecipeController(); 391new RecipeController();
329/* eslint-enable no-new */ 392/* eslint-enable no-new */
330
331// Patching window.open
332const originalWindowOpen = window.open;
333
334window.open = (url, frameName, features) => {
335 debug('window.open', url, frameName, features);
336 if (!url) {
337 // The service hasn't yet supplied a URL (as used in Skype).
338 // Return a new dummy window object and wait for the service to change the properties
339 const newWindow = {
340 location: {
341 href: '',
342 },
343 };
344
345 const checkInterval = setInterval(() => {
346 // Has the service changed the URL yet?
347 if (newWindow.location.href !== '') {
348 if (features) {
349 originalWindowOpen(newWindow.location.href, frameName, features);
350 } else {
351 // Open the new URL
352 ipcRenderer.sendToHost('new-window', newWindow.location.href);
353 }
354 clearInterval(checkInterval);
355 }
356 }, 0);
357
358 setTimeout(() => {
359 // Stop checking for location changes after 1 second
360 clearInterval(checkInterval);
361 }, 1000);
362
363 return newWindow;
364 }
365
366 // We need to differentiate if the link should be opened in a popup or in the systems default browser
367 if (!frameName && !features && typeof features !== 'string') {
368 return ipcRenderer.sendToHost('new-window', url);
369 }
370
371 if (url) {
372 return originalWindowOpen(url, frameName, features);
373 }
374};
375
376if (isDevMode) {
377 window.log = console.log;
378}
diff --git a/src/webview/screenshare.js b/src/webview/screenshare.js
index 84d2e1e95..ab548a625 100644
--- a/src/webview/screenshare.js
+++ b/src/webview/screenshare.js
@@ -2,6 +2,27 @@ import { desktopCapturer } from 'electron';
2 2
3const CANCEL_ID = 'desktop-capturer-selection__cancel'; 3const CANCEL_ID = 'desktop-capturer-selection__cancel';
4 4
5export async function getDisplayMediaSelector() {
6 const sources = await desktopCapturer.getSources({ types: ['screen', 'window'] });
7 return `<div class="desktop-capturer-selection__scroller">
8 <ul class="desktop-capturer-selection__list">
9 ${sources.map(({ id, name, thumbnail }) => `
10 <li class="desktop-capturer-selection__item">
11 <button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}">
12 <img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" />
13 <span class="desktop-capturer-selection__name">${name}</span>
14 </button>
15 </li>
16 `).join('')}
17 <li class="desktop-capturer-selection__item">
18 <button class="desktop-capturer-selection__btn" data-id="${CANCEL_ID}" title="Cancel">
19 <span class="desktop-capturer-selection__name desktop-capturer-selection__name--cancel">Cancel</span>
20 </button>
21 </li>
22 </ul>
23</div>`;
24}
25
5export const screenShareCss = ` 26export const screenShareCss = `
6.desktop-capturer-selection { 27.desktop-capturer-selection {
7 position: fixed; 28 position: fixed;
@@ -72,38 +93,12 @@ export const screenShareCss = `
72} 93}
73`; 94`;
74 95
75// Patch getDisplayMedia for screen sharing 96export const screenShareJs = `
76window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) => { 97window.navigator.mediaDevices.getDisplayMedia = () => new Promise(async (resolve, reject) => {
77 try { 98 try {
78 const sources = await desktopCapturer.getSources({
79 types: ['screen', 'window'],
80 });
81
82 const selectionElem = document.createElement('div'); 99 const selectionElem = document.createElement('div');
83 selectionElem.classList = 'desktop-capturer-selection'; 100 selectionElem.classList = ['desktop-capturer-selection'];
84 selectionElem.innerHTML = ` 101 selectionElem.innerHTML = await window.ferdi.getDisplayMediaSelector();
85 <div class="desktop-capturer-selection__scroller">
86 <ul class="desktop-capturer-selection__list">
87 ${sources
88 .map(
89 ({ id, name, thumbnail }) => `
90 <li class="desktop-capturer-selection__item">
91 <button class="desktop-capturer-selection__btn" data-id="${id}" title="${name}">
92 <img class="desktop-capturer-selection__thumbnail" src="${thumbnail.toDataURL()}" />
93 <span class="desktop-capturer-selection__name">${name}</span>
94 </button>
95 </li>
96 `,
97 )
98 .join('')}
99 <li class="desktop-capturer-selection__item">
100 <button class="desktop-capturer-selection__btn" data-id="${CANCEL_ID}" title="Cancel">
101 <span class="desktop-capturer-selection__name desktop-capturer-selection__name--cancel">Cancel</span>
102 </button>
103 </li>
104 </ul>
105 </div>
106 `;
107 document.body.appendChild(selectionElem); 102 document.body.appendChild(selectionElem);
108 103
109 document 104 document
@@ -112,25 +107,18 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) =>
112 button.addEventListener('click', async () => { 107 button.addEventListener('click', async () => {
113 try { 108 try {
114 const id = button.getAttribute('data-id'); 109 const id = button.getAttribute('data-id');
115 if (id === CANCEL_ID) { 110 if (id === '${CANCEL_ID}') {
116 reject(new Error('Cancelled by user')); 111 reject(new Error('Cancelled by user'));
117 } else { 112 } else {
118 const mediaSource = sources.find((source) => source.id === id); 113 const stream = await window.navigator.mediaDevices.getUserMedia({
119 if (!mediaSource) { 114 audio: false,
120 throw new Error(`Source with id ${id} does not exist`); 115 video: {
121 } 116 mandatory: {
122 117 chromeMediaSource: 'desktop',
123 const stream = await window.navigator.mediaDevices.getUserMedia( 118 chromeMediaSourceId: id,
124 {
125 audio: false,
126 video: {
127 mandatory: {
128 chromeMediaSource: 'desktop',
129 chromeMediaSourceId: mediaSource.id,
130 },
131 }, 119 },
132 }, 120 },
133 ); 121 });
134 resolve(stream); 122 resolve(stream);
135 } 123 }
136 } catch (err) { 124 } catch (err) {
@@ -143,4 +131,5 @@ window.navigator.mediaDevices.getDisplayMedia = () => async (resolve, reject) =>
143 } catch (err) { 131 } catch (err) {
144 reject(err); 132 reject(err);
145 } 133 }
146}; 134});
135`;