[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:
Guillermo Vayá 2020-01-17 19:52:05 +01:00 committed by Dean Whillier
parent b98e6a451d
commit 58cf6d5b28
5 changed files with 131 additions and 72 deletions

View file

@ -21,8 +21,10 @@
"build:main": "webpack-cli --bail --config webpack.config.main.js",
"build:renderer": "webpack-cli --bail --config webpack.config.renderer.js",
"start": "electron src --disable-dev-mode",
"restart": "npm run build && npm run start",
"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-install": "npm run clean && npm install",
"watch": "run-p watch:*",
"watch:main": "node scripts/watch_main_and_preload.js",
"watch:renderer": "webpack-dev-server --config webpack.config.renderer.js",

View file

@ -118,13 +118,16 @@ export default class MattermostView extends React.Component {
} else if (destURL.path.match(/^\/help\//)) {
// continue to open special case internal urls in default browser
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.
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, contextIsolation=yes, show=yes');
} else {
e.preventDefault();
shell.openExternal(e.url);
}
} else {
// if the link is external, use default browser.
shell.openExternal(e.url);
// if the link is external, use default os' application.
ipcRenderer.send('confirm-protocol', destURL.protocol, e.url);
}
});

View file

@ -5,8 +5,6 @@
import os from 'os';
import path from 'path';
import {URL} from 'url';
import electron from 'electron';
import isDev from 'electron-is-dev';
import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer';
@ -427,9 +425,12 @@ function handleAppWebContentsCreated(dc, contents) {
contents.on('will-navigate', (event, url) => {
const contentID = event.sender.id;
const parsedURL = parseURL(url);
if (isTrustedURL(parsedURL) || isTrustedPopupWindow(event.sender)) {
const parsedURL = Utils.parseURL(url);
const serverURL = Utils.getServer(parsedURL, config.teams);
if ((serverURL !== null && Utils.isTeamUrl(serverURL.url, parsedURL)) || isTrustedPopupWindow(event.sender)) {
return;
}
if (isCustomLoginURL(parsedURL)) {
return;
}
if (parsedURL.protocol === 'mailto:') {
@ -439,7 +440,7 @@ function handleAppWebContentsCreated(dc, contents) {
return;
}
log.info(`Untrusted URL blocked: ${url}`);
log.info(`Prevented desktop from navigating to: ${url}`);
event.preventDefault();
});
@ -450,7 +451,7 @@ function handleAppWebContentsCreated(dc, contents) {
// - indicate custom login is NOT in progress
contents.on('did-start-navigation', (event, url) => {
const contentID = event.sender.id;
const parsedURL = parseURL(url);
const parsedURL = Utils.parseURL(url);
if (!isTrustedURL(parsedURL)) {
return;
@ -465,19 +466,24 @@ function handleAppWebContentsCreated(dc, contents) {
contents.on('new-window', (event, url) => {
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}`);
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`);
return;
}
if (popupWindow && popupWindow.getURL() === url) {
if (popupWindow && !popupWindow.closed && popupWindow.getURL() === url) {
log.info(`Popup window already open at provided url: ${url}`);
return;
}
if (!popupWindow) {
if (Utils.isPluginUrl(server.url, parsedURL)) {
if (!popupWindow || popupWindow.closed) {
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
parent: mainWindow,
@ -495,6 +501,7 @@ function handleAppWebContentsCreated(dc, contents) {
});
}
popupWindow.loadURL(url);
}
});
// implemented to temporarily help solve for https://community-daily.mattermost.com/core/pl/b95bi44r4bbnueqzjjxsi46qiw
@ -924,51 +931,13 @@ function handleMainWindowWebContentsCrashed() {
// 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) {
const parsedURL = parseURL(url);
const parsedURL = Utils.parseURL(url);
if (!parsedURL) {
console.log('not an url');
return false;
}
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;
return Utils.getServer(parsedURL, config.teams) !== null;
}
function isTrustedPopupWindow(webContents) {
@ -982,7 +951,7 @@ function isTrustedPopupWindow(webContents) {
}
function isCustomLoginURL(url) {
const parsedURL = parseURL(url);
const parsedURL = Utils.parseURL(url);
if (!parsedURL) {
return false;
}

View file

@ -8,17 +8,33 @@ import fs from 'fs';
import {app, dialog, ipcMain, shell} from 'electron';
import {protocols} from '../../electron-builder.json';
import * as Validator from './Validator';
const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json');
let allowedProtocols = [];
function addScheme(scheme) {
const proto = `${scheme}:`;
if (!allowedProtocols.includes(proto)) {
allowedProtocols.push(proto);
}
}
function init(mainWindow) {
fs.readFile(allowedProtocolFile, 'utf-8', (err, data) => {
if (!err) {
allowedProtocols = JSON.parse(data);
allowedProtocols = Validator.validateAllowedProtocols(allowedProtocols) || [];
}
addScheme('http');
addScheme('https');
protocols.forEach((protocol) => {
if (protocol.schemes && protocol.schemes.length > 0) {
protocol.schemes.forEach(addScheme);
}
});
initDialogEvent(mainWindow);
});
}

View file

@ -19,6 +19,20 @@ function isValidURI(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.
// - currentURL is the current url inside the webview
// - 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;
}
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() {
const {screen} = electron;
@ -56,5 +121,9 @@ export default {
isValidURL,
isValidURI,
isInternalURL,
parseURL,
getServer,
isTeamUrl,
isPluginUrl,
getDisplayBoundaries,
};