diff --git a/package.json b/package.json index 655ebb89..a9d882f9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/browser/components/MattermostView.jsx b/src/browser/components/MattermostView.jsx index 5f41ba10..a4ed429b 100644 --- a/src/browser/components/MattermostView.jsx +++ b/src/browser/components/MattermostView.jsx @@ -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); } }); diff --git a/src/main.js b/src/main.js index 42810fb3..c55cd433 100644 --- a/src/main.js +++ b/src/main.js @@ -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,36 +466,42 @@ 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) { - 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, - show: false, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - popupWindow.once('ready-to-show', () => { - popupWindow.show(); - }); - popupWindow.once('closed', () => { - popupWindow = null; - }); + 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, + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + popupWindow.once('ready-to-show', () => { + popupWindow.show(); + }); + popupWindow.once('closed', () => { + popupWindow = null; + }); + } + popupWindow.loadURL(url); } - 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; } diff --git a/src/main/allowProtocolDialog.js b/src/main/allowProtocolDialog.js index de49168c..807cf492 100644 --- a/src/main/allowProtocolDialog.js +++ b/src/main/allowProtocolDialog.js @@ -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); }); } diff --git a/src/utils/util.js b/src/utils/util.js index ca957136..aa25957f 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -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, };