[MM-20363] prevent using api to navigate within the app's window (#1137)
* prevent using api to navigate within the app's window * api calls handled differently * add aliases to ease development * small refactor of url navigation * hardcode http/s protocol being allowed * add protocols specified on electron-builder
This commit is contained in:

committed by
Dean Whillier

parent
b98e6a451d
commit
58cf6d5b28
@@ -21,8 +21,10 @@
|
|||||||
"build:main": "webpack-cli --bail --config webpack.config.main.js",
|
"build:main": "webpack-cli --bail --config webpack.config.main.js",
|
||||||
"build:renderer": "webpack-cli --bail --config webpack.config.renderer.js",
|
"build:renderer": "webpack-cli --bail --config webpack.config.renderer.js",
|
||||||
"start": "electron src --disable-dev-mode",
|
"start": "electron src --disable-dev-mode",
|
||||||
|
"restart": "npm run build && npm run start",
|
||||||
"storybook": "start-storybook -p 9001 -c src/.storybook",
|
"storybook": "start-storybook -p 9001 -c src/.storybook",
|
||||||
"clean": "rm -rf release/ node_modules/ src/node_modules/ && find src -name '*_bundle.js' | xargs rm",
|
"clean": "rm -rf release/ node_modules/ src/node_modules/ && find src -name '*_bundle.js' | xargs rm",
|
||||||
|
"clean-install": "npm run clean && npm install",
|
||||||
"watch": "run-p watch:*",
|
"watch": "run-p watch:*",
|
||||||
"watch:main": "node scripts/watch_main_and_preload.js",
|
"watch:main": "node scripts/watch_main_and_preload.js",
|
||||||
"watch:renderer": "webpack-dev-server --config webpack.config.renderer.js",
|
"watch:renderer": "webpack-dev-server --config webpack.config.renderer.js",
|
||||||
|
@@ -118,13 +118,16 @@ export default class MattermostView extends React.Component {
|
|||||||
} else if (destURL.path.match(/^\/help\//)) {
|
} else if (destURL.path.match(/^\/help\//)) {
|
||||||
// continue to open special case internal urls in default browser
|
// continue to open special case internal urls in default browser
|
||||||
shell.openExternal(e.url);
|
shell.openExternal(e.url);
|
||||||
} else {
|
} else if (Utils.isTeamUrl(this.props.src, e.url, true) || Utils.isPluginUrl(this.props.src, e.url)) {
|
||||||
// New window should disable nodeIntegration.
|
// New window should disable nodeIntegration.
|
||||||
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, contextIsolation=yes, show=yes');
|
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, contextIsolation=yes, show=yes');
|
||||||
|
} else {
|
||||||
|
e.preventDefault();
|
||||||
|
shell.openExternal(e.url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// if the link is external, use default browser.
|
// if the link is external, use default os' application.
|
||||||
shell.openExternal(e.url);
|
ipcRenderer.send('confirm-protocol', destURL.protocol, e.url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
75
src/main.js
75
src/main.js
@@ -5,8 +5,6 @@
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {URL} from 'url';
|
|
||||||
|
|
||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import isDev from 'electron-is-dev';
|
import isDev from 'electron-is-dev';
|
||||||
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
|
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
|
||||||
@@ -427,9 +425,12 @@ function handleAppWebContentsCreated(dc, contents) {
|
|||||||
|
|
||||||
contents.on('will-navigate', (event, url) => {
|
contents.on('will-navigate', (event, url) => {
|
||||||
const contentID = event.sender.id;
|
const contentID = event.sender.id;
|
||||||
const parsedURL = parseURL(url);
|
const parsedURL = Utils.parseURL(url);
|
||||||
|
const serverURL = Utils.getServer(parsedURL, config.teams);
|
||||||
if (isTrustedURL(parsedURL) || isTrustedPopupWindow(event.sender)) {
|
if ((serverURL !== null && Utils.isTeamUrl(serverURL.url, parsedURL)) || isTrustedPopupWindow(event.sender)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCustomLoginURL(parsedURL)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsedURL.protocol === 'mailto:') {
|
if (parsedURL.protocol === 'mailto:') {
|
||||||
@@ -439,7 +440,7 @@ function handleAppWebContentsCreated(dc, contents) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`Untrusted URL blocked: ${url}`);
|
log.info(`Prevented desktop from navigating to: ${url}`);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,7 +451,7 @@ function handleAppWebContentsCreated(dc, contents) {
|
|||||||
// - indicate custom login is NOT in progress
|
// - indicate custom login is NOT in progress
|
||||||
contents.on('did-start-navigation', (event, url) => {
|
contents.on('did-start-navigation', (event, url) => {
|
||||||
const contentID = event.sender.id;
|
const contentID = event.sender.id;
|
||||||
const parsedURL = parseURL(url);
|
const parsedURL = Utils.parseURL(url);
|
||||||
|
|
||||||
if (!isTrustedURL(parsedURL)) {
|
if (!isTrustedURL(parsedURL)) {
|
||||||
return;
|
return;
|
||||||
@@ -465,19 +466,24 @@ function handleAppWebContentsCreated(dc, contents) {
|
|||||||
|
|
||||||
contents.on('new-window', (event, url) => {
|
contents.on('new-window', (event, url) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!isTrustedURL(url)) {
|
|
||||||
|
const parsedURL = Utils.parseURL(url);
|
||||||
|
const server = Utils.getServer(parsedURL, config.teams);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
log.info(`Untrusted popup window blocked: ${url}`);
|
log.info(`Untrusted popup window blocked: ${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTeamUrl(url) === true) {
|
if (Utils.isTeamUrl(server.url, parsedURL, true) === true) {
|
||||||
log.info(`${url} is a known team, preventing to open a new window`);
|
log.info(`${url} is a known team, preventing to open a new window`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (popupWindow && popupWindow.getURL() === url) {
|
if (popupWindow && !popupWindow.closed && popupWindow.getURL() === url) {
|
||||||
log.info(`Popup window already open at provided url: ${url}`);
|
log.info(`Popup window already open at provided url: ${url}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!popupWindow) {
|
if (Utils.isPluginUrl(server.url, parsedURL)) {
|
||||||
|
if (!popupWindow || popupWindow.closed) {
|
||||||
popupWindow = new BrowserWindow({
|
popupWindow = new BrowserWindow({
|
||||||
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
|
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
|
||||||
parent: mainWindow,
|
parent: mainWindow,
|
||||||
@@ -495,6 +501,7 @@ function handleAppWebContentsCreated(dc, contents) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
popupWindow.loadURL(url);
|
popupWindow.loadURL(url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// implemented to temporarily help solve for https://community-daily.mattermost.com/core/pl/b95bi44r4bbnueqzjjxsi46qiw
|
// implemented to temporarily help solve for https://community-daily.mattermost.com/core/pl/b95bi44r4bbnueqzjjxsi46qiw
|
||||||
@@ -924,51 +931,13 @@ function handleMainWindowWebContentsCrashed() {
|
|||||||
// helper functions
|
// helper functions
|
||||||
//
|
//
|
||||||
|
|
||||||
function parseURL(url) {
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (url instanceof URL) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return new URL(url);
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTeamUrl(url) {
|
|
||||||
const parsedURL = parseURL(url);
|
|
||||||
if (!parsedURL) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (isCustomLoginURL(parsedURL)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const nonTeamUrlPaths = ['plugins', 'signup', 'login', 'admin', 'channel', 'post', 'api', 'oauth'];
|
|
||||||
return !(nonTeamUrlPaths.some((testPath) => parsedURL.pathname.toLowerCase().startsWith(`/${testPath}/`)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTrustedURL(url) {
|
function isTrustedURL(url) {
|
||||||
const parsedURL = parseURL(url);
|
const parsedURL = Utils.parseURL(url);
|
||||||
if (!parsedURL) {
|
if (!parsedURL) {
|
||||||
|
console.log('not an url');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return Utils.getServer(parsedURL, config.teams) !== null;
|
||||||
const teamURLs = config.teams.reduce((urls, team) => {
|
|
||||||
const parsedTeamURL = parseURL(team.url);
|
|
||||||
if (parsedTeamURL) {
|
|
||||||
return urls.concat(parsedTeamURL);
|
|
||||||
}
|
|
||||||
return urls;
|
|
||||||
}, []);
|
|
||||||
for (const teamURL of teamURLs) {
|
|
||||||
if (parsedURL.origin === teamURL.origin) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTrustedPopupWindow(webContents) {
|
function isTrustedPopupWindow(webContents) {
|
||||||
@@ -982,7 +951,7 @@ function isTrustedPopupWindow(webContents) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isCustomLoginURL(url) {
|
function isCustomLoginURL(url) {
|
||||||
const parsedURL = parseURL(url);
|
const parsedURL = Utils.parseURL(url);
|
||||||
if (!parsedURL) {
|
if (!parsedURL) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@@ -8,17 +8,33 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import {app, dialog, ipcMain, shell} from 'electron';
|
import {app, dialog, ipcMain, shell} from 'electron';
|
||||||
|
|
||||||
|
import {protocols} from '../../electron-builder.json';
|
||||||
|
|
||||||
import * as Validator from './Validator';
|
import * as Validator from './Validator';
|
||||||
|
|
||||||
const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json');
|
const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json');
|
||||||
let allowedProtocols = [];
|
let allowedProtocols = [];
|
||||||
|
|
||||||
|
function addScheme(scheme) {
|
||||||
|
const proto = `${scheme}:`;
|
||||||
|
if (!allowedProtocols.includes(proto)) {
|
||||||
|
allowedProtocols.push(proto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function init(mainWindow) {
|
function init(mainWindow) {
|
||||||
fs.readFile(allowedProtocolFile, 'utf-8', (err, data) => {
|
fs.readFile(allowedProtocolFile, 'utf-8', (err, data) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
allowedProtocols = JSON.parse(data);
|
allowedProtocols = JSON.parse(data);
|
||||||
allowedProtocols = Validator.validateAllowedProtocols(allowedProtocols) || [];
|
allowedProtocols = Validator.validateAllowedProtocols(allowedProtocols) || [];
|
||||||
}
|
}
|
||||||
|
addScheme('http');
|
||||||
|
addScheme('https');
|
||||||
|
protocols.forEach((protocol) => {
|
||||||
|
if (protocol.schemes && protocol.schemes.length > 0) {
|
||||||
|
protocol.schemes.forEach(addScheme);
|
||||||
|
}
|
||||||
|
});
|
||||||
initDialogEvent(mainWindow);
|
initDialogEvent(mainWindow);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -19,6 +19,20 @@ function isValidURI(testURL) {
|
|||||||
return Boolean(isUri(testURL));
|
return Boolean(isUri(testURL));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseURL(inputURL) {
|
||||||
|
if (!inputURL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (inputURL instanceof URL) {
|
||||||
|
return inputURL;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(inputURL);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isInternalURL determines if the target url is internal to the application.
|
// isInternalURL determines if the target url is internal to the application.
|
||||||
// - currentURL is the current url inside the webview
|
// - currentURL is the current url inside the webview
|
||||||
// - basename is the global export from the Mattermost application defining the subpath, if any
|
// - basename is the global export from the Mattermost application defining the subpath, if any
|
||||||
@@ -34,6 +48,57 @@ function isInternalURL(targetURL, currentURL, basename = '/') {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServerInfo(serverUrl) {
|
||||||
|
const parsedServer = parseURL(serverUrl);
|
||||||
|
if (!parsedServer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// does the server have a subpath?
|
||||||
|
const pn = parsedServer.pathname.toLowerCase();
|
||||||
|
const subpath = pn.endsWith('/') ? pn.toLowerCase() : `${pn}/`;
|
||||||
|
return {origin: parsedServer.origin, subpath, url: parsedServer};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTeamUrl(serverUrl, inputUrl, withApi) {
|
||||||
|
const parsedURL = parseURL(inputUrl);
|
||||||
|
const server = getServerInfo(serverUrl);
|
||||||
|
if (!parsedURL || !server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const nonTeamUrlPaths = ['plugins', 'signup', 'login', 'admin', 'channel', 'post', 'oauth', 'admin_console'];
|
||||||
|
if (withApi) {
|
||||||
|
nonTeamUrlPaths.push('api');
|
||||||
|
}
|
||||||
|
return !(nonTeamUrlPaths.some((testPath) => parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${testPath}/`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPluginUrl(serverUrl, inputURL) {
|
||||||
|
const server = getServerInfo(serverUrl);
|
||||||
|
const parsedURL = parseURL(inputURL);
|
||||||
|
if (!parsedURL || !server) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return server.origin === parsedURL.origin && parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}plugins/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServer(inputURL, teams) {
|
||||||
|
const parsedURL = parseURL(inputURL);
|
||||||
|
if (!parsedURL) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let parsedServerUrl;
|
||||||
|
for (let i = 0; i < teams.length; i++) {
|
||||||
|
parsedServerUrl = parseURL(teams[i].url);
|
||||||
|
|
||||||
|
// check server and subpath matches (without subpath pathname is \ so it always matches)
|
||||||
|
if (parsedServerUrl.origin === parsedURL.origin && parsedURL.pathname.startsWith(parsedServerUrl.pathname)) {
|
||||||
|
return {name: teams[i].name, url: parsedServerUrl};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getDisplayBoundaries() {
|
function getDisplayBoundaries() {
|
||||||
const {screen} = electron;
|
const {screen} = electron;
|
||||||
|
|
||||||
@@ -56,5 +121,9 @@ export default {
|
|||||||
isValidURL,
|
isValidURL,
|
||||||
isValidURI,
|
isValidURI,
|
||||||
isInternalURL,
|
isInternalURL,
|
||||||
|
parseURL,
|
||||||
|
getServer,
|
||||||
|
isTeamUrl,
|
||||||
|
isPluginUrl,
|
||||||
getDisplayBoundaries,
|
getDisplayBoundaries,
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user