From 5d0a937bb9bd632fb48016581b3daff168d4a53d Mon Sep 17 00:00:00 2001 From: FalseHonesty Date: Wed, 4 Nov 2020 14:59:07 -0500 Subject: [PATCH] [MM-21835] Use URL instead of the url library (#1384) Additionally, migrate all of the URL related helper functions from `src/utils/utils.js` to the new `src/utils/url.js` file and migrate tests. Issue MM-21835 Fixes #1206 --- src/browser/components/MainPage.jsx | 22 +- src/browser/components/MattermostView.jsx | 23 +-- src/browser/components/NewTeamModal.jsx | 4 +- src/browser/components/externalLink.jsx | 6 +- src/browser/index.jsx | 11 +- src/browser/js/contextMenu.js | 4 +- src/browser/updater.jsx | 7 +- src/common/permissions.js | 3 +- src/main.js | 59 +++--- src/main/Validator.js | 6 +- src/main/certificateStore.js | 13 +- src/main/trustedOrigins.js | 12 +- src/utils/url.js | 190 ++++++++++++++++++ src/utils/util.js | 185 ----------------- test/modules/utils.js | 1 - .../specs/utils/{util_test.js => url_test.js} | 61 +++--- 16 files changed, 308 insertions(+), 299 deletions(-) create mode 100644 src/utils/url.js rename test/specs/utils/{util_test.js => url_test.js} (58%) diff --git a/src/browser/components/MainPage.jsx b/src/browser/components/MainPage.jsx index b6dddf7a..37e32954 100644 --- a/src/browser/components/MainPage.jsx +++ b/src/browser/components/MainPage.jsx @@ -6,7 +6,6 @@ /* eslint-disable react/no-set-state */ import os from 'os'; -import url from 'url'; import React, {Fragment} from 'react'; import PropTypes from 'prop-types'; @@ -17,6 +16,7 @@ import DotsVerticalIcon from 'mdi-react/DotsVerticalIcon'; import {ipcRenderer, remote, shell} from 'electron'; import Utils from '../../utils/util'; +import urlUtils from '../../utils/url'; import contextmenu from '../js/contextMenu'; import restoreButton from '../../assets/titlebar/chrome-restore.svg'; @@ -75,16 +75,16 @@ export default class MainPage extends React.Component { parseDeeplinkURL(deeplink, teams = this.props.teams) { if (deeplink && Array.isArray(teams) && teams.length) { - const deeplinkURL = url.parse(deeplink); + const deeplinkURL = urlUtils.parseURL(deeplink); let parsedDeeplink = null; teams.forEach((team, index) => { - const teamURL = url.parse(team.url); + const teamURL = urlUtils.parseURL(team.url); if (deeplinkURL.host === teamURL.host) { parsedDeeplink = { teamURL, teamIndex: index, originalURL: deeplinkURL, - url: `${teamURL.protocol}//${teamURL.host}${deeplinkURL.pathname || '/'}`, + url: `${teamURL.origin}${deeplinkURL.pathname || '/'}`, path: deeplinkURL.pathname || '/', }; } @@ -389,18 +389,18 @@ export default class MainPage extends React.Component { switchToTabForCertificateRequest = (origin) => { // origin is server name + port, if the port doesn't match the protocol, it is kept by URL - const originURL = new URL(`http://${origin.split(':')[0]}`); - const secureOriginURL = new URL(`https://${origin.split(':')[0]}`); + const originURL = urlUtils.parseURL(`http://${origin.split(':')[0]}`); + const secureOriginURL = urlUtils.parseURL(`https://${origin.split(':')[0]}`); const key = this.props.teams.findIndex((team) => { - const parsedURL = new URL(team.url); + const parsedURL = urlUtils.parseURL(team.url); return (parsedURL.origin === originURL.origin) || (parsedURL.origin === secureOriginURL.origin); }); this.handleSelect(key); }; handleInterTeamLink = (linkUrl) => { - const selectedTeam = Utils.getServer(linkUrl, this.props.teams); + const selectedTeam = urlUtils.getServer(linkUrl, this.props.teams); if (!selectedTeam) { return; } @@ -649,7 +649,7 @@ export default class MainPage extends React.Component { showExtraBar = () => { const ref = this.refs[`mattermostView${this.state.key}`]; if (typeof ref !== 'undefined') { - return !Utils.isTeamUrl(this.props.teams[this.state.key].url, ref.getSrc()); + return !urlUtils.isTeamUrl(this.props.teams[this.state.key].url, ref.getSrc()); } return false; } @@ -814,8 +814,8 @@ export default class MainPage extends React.Component { let authInfo = null; if (this.state.loginQueue.length !== 0) { request = this.state.loginQueue[0].request; - const tmpURL = url.parse(this.state.loginQueue[0].request.url); - authServerURL = `${tmpURL.protocol}//${tmpURL.host}`; + const tmpURL = urlUtils.parseURL(this.state.loginQueue[0].request.url); + authServerURL = tmpURL.origin; authInfo = this.state.loginQueue[0].authInfo; } const modal = ( diff --git a/src/browser/components/MattermostView.jsx b/src/browser/components/MattermostView.jsx index 8f5da834..ea70b4c3 100644 --- a/src/browser/components/MattermostView.jsx +++ b/src/browser/components/MattermostView.jsx @@ -5,14 +5,13 @@ // This file uses setState(). /* eslint-disable react/no-set-state */ -import url from 'url'; - import React from 'react'; import PropTypes from 'prop-types'; import {ipcRenderer, remote, shell} from 'electron'; import contextMenu from '../js/contextMenu'; import Utils from '../../utils/util'; +import urlUtils from '../../utils/url'; import {protocols} from '../../../electron-builder.json'; const scheme = protocols[0].schemes[0]; @@ -78,38 +77,38 @@ export default class MattermostView extends React.Component { // Open link in browserWindow. for example, attached files. webview.addEventListener('new-window', (e) => { - if (!Utils.isValidURI(e.url)) { + if (!urlUtils.isValidURI(e.url)) { return; } - const currentURL = url.parse(webview.getURL()); - const destURL = url.parse(e.url); + const currentURL = urlUtils.parseURL(webview.getURL()); + const destURL = urlUtils.parseURL(e.url); if (destURL.protocol !== 'http:' && destURL.protocol !== 'https:' && destURL.protocol !== `${scheme}:`) { ipcRenderer.send('confirm-protocol', destURL.protocol, e.url); return; } - if (Utils.isInternalURL(destURL, currentURL, this.state.basename)) { + if (urlUtils.isInternalURL(destURL, currentURL, this.state.basename)) { if (destURL.path.match(/^\/api\/v[3-4]\/public\/files\//)) { ipcRenderer.send('download-url', e.url); } else if (destURL.path.match(/^\/help\//)) { // continue to open special case internal urls in default browser shell.openExternal(e.url); - } else if (Utils.isTeamUrl(this.props.src, e.url, true) || Utils.isAdminUrl(this.props.src, e.url)) { + } else if (urlUtils.isTeamUrl(this.props.src, e.url, true) || urlUtils.isAdminUrl(this.props.src, e.url)) { e.preventDefault(); this.webviewRef.current.loadURL(e.url); - } else if (Utils.isPluginUrl(this.props.src, e.url)) { + } else if (urlUtils.isPluginUrl(this.props.src, e.url)) { // New window should disable nodeIntegration. window.open(e.url, remote.app.name, 'nodeIntegration=no, contextIsolation=yes, show=yes'); - } else if (Utils.isManagedResource(this.props.src, e.url)) { + } else if (urlUtils.isManagedResource(this.props.src, e.url)) { e.preventDefault(); } else { e.preventDefault(); shell.openExternal(e.url); } } else { - const parsedURL = Utils.parseURL(e.url); - const serverURL = Utils.getServer(parsedURL, this.props.teams); - if (serverURL !== null && Utils.isTeamUrl(serverURL.url, parsedURL)) { + const parsedURL = urlUtils.parseURL(e.url); + const serverURL = urlUtils.getServer(parsedURL, this.props.teams); + if (serverURL !== null && urlUtils.isTeamUrl(serverURL.url, parsedURL)) { this.props.handleInterTeamLink(parsedURL); } else { // if the link is external, use default os' application. diff --git a/src/browser/components/NewTeamModal.jsx b/src/browser/components/NewTeamModal.jsx index e2d694c9..47fe3798 100644 --- a/src/browser/components/NewTeamModal.jsx +++ b/src/browser/components/NewTeamModal.jsx @@ -6,7 +6,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} from 'react-bootstrap'; -import Utils from '../../utils/util'; +import urlUtils from '../../utils/url'; export default class NewTeamModal extends React.Component { static defaultProps = { @@ -62,7 +62,7 @@ export default class NewTeamModal extends React.Component { if (!(/^https?:\/\/.*/).test(this.state.teamUrl.trim())) { return 'URL should start with http:// or https://.'; } - if (!Utils.isValidURL(this.state.teamUrl.trim())) { + if (!urlUtils.isValidURL(this.state.teamUrl.trim())) { return 'URL is not formatted correctly.'; } return null; diff --git a/src/browser/components/externalLink.jsx b/src/browser/components/externalLink.jsx index 56fe7a57..82897ed0 100644 --- a/src/browser/components/externalLink.jsx +++ b/src/browser/components/externalLink.jsx @@ -5,6 +5,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {ipcRenderer} from 'electron'; +import urlUtils from '../../utils/url'; + // this component is used to override some checks from the UI, leaving only to trust the protocol in case it wasn't http/s // it is used the same as an `a` JSX tag export default function ExternalLink(props) { @@ -12,7 +14,7 @@ export default function ExternalLink(props) { e.preventDefault(); let parseUrl; try { - parseUrl = new URL(props.href); + parseUrl = urlUtils.parseURL(props.href); ipcRenderer.send('confirm-protocol', parseUrl.protocol, props.href); } catch (err) { console.error(`invalid url ${props.href} supplied to externallink: ${err}`); @@ -29,4 +31,4 @@ export default function ExternalLink(props) { ExternalLink.propTypes = { href: PropTypes.string.isRequired, -}; \ No newline at end of file +}; diff --git a/src/browser/index.jsx b/src/browser/index.jsx index 998ce337..aa33dd16 100644 --- a/src/browser/index.jsx +++ b/src/browser/index.jsx @@ -8,12 +8,12 @@ window.eval = global.eval = () => { // eslint-disable-line no-multi-assign, no-e throw new Error('Sorry, Mattermost does not support window.eval() for security reasons.'); }; -import url from 'url'; - import React from 'react'; import ReactDOM from 'react-dom'; import {remote, ipcRenderer} from 'electron'; +import urlUtils from '../utils/url'; + import Config from '../common/config'; import EnhancedNotification from './js/notification'; @@ -32,11 +32,12 @@ if (teams.length === 0) { remote.getCurrentWindow().loadFile('browser/settings.html'); } -const parsedURL = url.parse(window.location.href, true); -const initialIndex = parsedURL.query.index ? parseInt(parsedURL.query.index, 10) : getInitialIndex(); +const parsedURLSearchParams = urlUtils.parseURL(window.location.href).searchParams; +const parsedURLHasIndex = parsedURLSearchParams.has('index'); +const initialIndex = parsedURLHasIndex ? parseInt(parsedURLSearchParams.get('index'), 10) : getInitialIndex(); let deeplinkingUrl = null; -if (!parsedURL.query.index || parsedURL.query.index === null) { +if (!parsedURLHasIndex) { deeplinkingUrl = remote.getCurrentWindow().deeplinkingUrl; } diff --git a/src/browser/js/contextMenu.js b/src/browser/js/contextMenu.js index f2a43e15..6cf7b83a 100644 --- a/src/browser/js/contextMenu.js +++ b/src/browser/js/contextMenu.js @@ -4,6 +4,8 @@ import {ipcRenderer, remote} from 'electron'; import electronContextMenu from 'electron-context-menu'; +import urlUtils from '../../utils/url'; + function getSuggestionsMenus(webcontents, suggestions) { if (suggestions.length === 0) { return [{ @@ -57,7 +59,7 @@ export default { const isInternalLink = p.linkURL.endsWith('#') && p.linkURL.slice(0, -1) === p.pageURL; let isInternalSrc; try { - const srcurl = new URL(p.srcURL); + const srcurl = urlUtils.parseURL(p.srcURL); isInternalSrc = srcurl.protocol === 'file:'; console.log(`srcrurl protocol: ${srcurl.protocol}`); } catch (err) { diff --git a/src/browser/updater.jsx b/src/browser/updater.jsx index ef4b1c76..9e3eeb50 100644 --- a/src/browser/updater.jsx +++ b/src/browser/updater.jsx @@ -1,17 +1,18 @@ // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import url from 'url'; import React from 'react'; import ReactDOM from 'react-dom'; import propTypes from 'prop-types'; import {ipcRenderer, remote} from 'electron'; +import urlUtils from '../utils/url'; + import UpdaterPage from './components/UpdaterPage.jsx'; -const thisURL = url.parse(location.href, true); -const notifyOnly = thisURL.query.notifyOnly === 'true'; +const thisURL = urlUtils.parseURL(location.href); +const notifyOnly = thisURL.searchParams.get('notifyOnly') === 'true'; class UpdaterPageContainer extends React.Component { constructor(props) { diff --git a/src/common/permissions.js b/src/common/permissions.js index 86b629ff..cdfc3487 100644 --- a/src/common/permissions.js +++ b/src/common/permissions.js @@ -1,4 +1,3 @@ - // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. @@ -13,4 +12,4 @@ export const BASIC_AUTH_PERMISSION = 'canBasicAuth'; // Permission descriptions export const PERMISSION_DESCRIPTION = { [BASIC_AUTH_PERMISSION]: 'Web Authentication', -}; \ No newline at end of file +}; diff --git a/src/main.js b/src/main.js index 27092b63..be3dce8c 100644 --- a/src/main.js +++ b/src/main.js @@ -31,8 +31,14 @@ import initCookieManager from './main/cookieManager'; import SpellChecker from './main/SpellChecker'; import UserActivityMonitor from './main/UserActivityMonitor'; import Utils from './utils/util'; +import urlUtils from './utils/url'; import parseArgs from './main/ParseArgs'; -import {REQUEST_PERMISSION_CHANNEL, GRANT_PERMISSION_CHANNEL, DENY_PERMISSION_CHANNEL, BASIC_AUTH_PERMISSION} from './common/permissions'; +import { + REQUEST_PERMISSION_CHANNEL, + GRANT_PERMISSION_CHANNEL, + DENY_PERMISSION_CHANNEL, + BASIC_AUTH_PERMISSION +} from './common/permissions'; // pull out required electron components like this // as not all components can be referenced before the app is ready @@ -357,7 +363,7 @@ function handleSelectedCertificate(event, server, cert) { } function handleAppCertificateError(event, webContents, url, error, certificate, callback) { - const parsedURL = new URL(url); + const parsedURL = urlUtils.parseURL(url); if (!parsedURL) { return; } @@ -425,8 +431,8 @@ function handleAppGPUProcessCrashed(event, killed) { function handleAppLogin(event, webContents, request, authInfo, callback) { event.preventDefault(); - const parsedURL = new URL(request.url); - const server = Utils.getServer(parsedURL, config.teams); + const parsedURL = urlUtils.parseURL(request.url); + const server = urlUtils.getServer(parsedURL, config.teams); loginCallbackMap.set(request.url, typeof callback === 'undefined' ? null : callback); // if callback is undefined set it to null instead so we know we have set it up with no value if (isTrustedURL(request.url) || isCustomLoginURL(parsedURL, server) || trustedOriginsStore.checkPermission(request.url, BASIC_AUTH_PERMISSION)) { @@ -461,6 +467,7 @@ function handleAppWillFinishLaunching() { setTimeout(openDeepLink, 1000); } } + openDeepLink(); } }); @@ -479,10 +486,10 @@ function handleAppWebContentsCreated(dc, contents) { contents.on('will-navigate', (event, url) => { const contentID = event.sender.id; - const parsedURL = Utils.parseURL(url); - const server = Utils.getServer(parsedURL, config.teams); + const parsedURL = urlUtils.parseURL(url); + const server = urlUtils.getServer(parsedURL, config.teams); - if ((server !== null && (Utils.isTeamUrl(server.url, parsedURL) || Utils.isAdminUrl(server.url, parsedURL))) || + if ((server !== null && (urlUtils.isTeamUrl(server.url, parsedURL) || urlUtils.isAdminUrl(server.url, parsedURL))) || isTrustedPopupWindow(event.sender)) { return; } @@ -508,8 +515,8 @@ 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 = Utils.parseURL(url); - const server = Utils.getServer(parsedURL, config.teams); + const parsedURL = urlUtils.parseURL(url); + const server = urlUtils.getServer(parsedURL, config.teams); if (!isTrustedURL(parsedURL)) { return; @@ -525,18 +532,18 @@ function handleAppWebContentsCreated(dc, contents) { contents.on('new-window', (event, url) => { event.preventDefault(); - const parsedURL = Utils.parseURL(url); - const server = Utils.getServer(parsedURL, config.teams); + const parsedURL = urlUtils.parseURL(url); + const server = urlUtils.getServer(parsedURL, config.teams); if (!server) { log.info(`Untrusted popup window blocked: ${url}`); return; } - if (Utils.isTeamUrl(server.url, parsedURL, true)) { + if (urlUtils.isTeamUrl(server.url, parsedURL, true)) { log.info(`${url} is a known team, preventing to open a new window`); return; } - if (Utils.isAdminUrl(server.url, parsedURL)) { + if (urlUtils.isAdminUrl(server.url, parsedURL)) { log.info(`${url} is an admin console page, preventing to open a new window`); return; } @@ -544,7 +551,7 @@ function handleAppWebContentsCreated(dc, contents) { log.info(`Popup window already open at provided url: ${url}`); return; } - if (Utils.isPluginUrl(server.url, parsedURL) || Utils.isManagedResource(server.url, parsedURL)) { + if (urlUtils.isPluginUrl(server.url, parsedURL) || urlUtils.isManagedResource(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 @@ -564,7 +571,7 @@ function handleAppWebContentsCreated(dc, contents) { }); } - if (Utils.isManagedResource(server.url, parsedURL)) { + if (urlUtils.isManagedResource(server.url, parsedURL)) { popupWindow.loadURL(url); } else { // currently changing the userAgent for popup windows to allow plugins to go through google's oAuth @@ -621,8 +628,8 @@ function handleAppWebContentsCreated(dc, contents) { mainWindow.webContents.send('zoom-reset'); break; - // Manually handle undo/redo keyboard shortcuts - // - temporary fix for https://mattermost.atlassian.net/browse/MM-19198 + // Manually handle undo/redo keyboard shortcuts + // - temporary fix for https://mattermost.atlassian.net/browse/MM-19198 case 'z': if (input.shift) { mainWindow.webContents.send('redo'); @@ -631,7 +638,7 @@ function handleAppWebContentsCreated(dc, contents) { } break; - // Manually handle copy/cut/paste keyboard shortcuts + // Manually handle copy/cut/paste keyboard shortcuts case 'c': mainWindow.webContents.send('copy'); break; @@ -791,7 +798,7 @@ function initializeAfterAppReady() { mainWindow.webContents.send('download-complete', { fileName: filename, path: item.savePath, - serverInfo: Utils.getServer(webContents.getURL(), config.teams), + serverInfo: urlUtils.getServer(webContents.getURL(), config.teams), }); } }); @@ -1026,11 +1033,11 @@ function handleMainWindowWebContentsCrashed() { // function isTrustedURL(url) { - const parsedURL = Utils.parseURL(url); + const parsedURL = urlUtils.parseURL(url); if (!parsedURL) { return false; } - return Utils.getServer(parsedURL, config.teams) !== null; + return urlUtils.getServer(parsedURL, config.teams) !== null; } function isTrustedPopupWindow(webContents) { @@ -1045,7 +1052,7 @@ function isTrustedPopupWindow(webContents) { function isCustomLoginURL(url, server) { const subpath = (server === null || typeof server === 'undefined') ? '' : server.url.pathname; - const parsedURL = Utils.parseURL(url); + const parsedURL = urlUtils.parseURL(url); if (!parsedURL) { return false; } @@ -1080,8 +1087,7 @@ function getTrayImages() { unread: nativeImage.createFromPath(path.resolve(assetsDir, 'windows/tray_unread.ico')), mention: nativeImage.createFromPath(path.resolve(assetsDir, 'windows/tray_mention.ico')), }; - case 'darwin': - { + case 'darwin': { const icons = { light: { normal: nativeImage.createFromPath(path.resolve(assetsDir, 'osx/MenuIcon.png')), @@ -1097,8 +1103,7 @@ function getTrayImages() { switchMenuIconImages(icons, nativeTheme.shouldUseDarkColors); return icons; } - case 'linux': - { + case 'linux': { const theme = config.trayIconTheme; try { return { @@ -1136,7 +1141,7 @@ function getDeeplinkingURL(args) { if (Array.isArray(args) && args.length) { // deeplink urls should always be the last argument, but may not be the first (i.e. Windows with the app already running) const url = args[args.length - 1]; - if (url && scheme && url.startsWith(scheme) && Utils.isValidURI(url)) { + if (url && scheme && url.startsWith(scheme) && urlUtils.isValidURI(url)) { return url; } } diff --git a/src/main/Validator.js b/src/main/Validator.js index c17e88f0..9894d429 100644 --- a/src/main/Validator.js +++ b/src/main/Validator.js @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import Joi from '@hapi/joi'; -import Utils from '../utils/util'; +import urlUtils from '../utils/url'; const defaultOptions = { stripUnknown: true, @@ -138,7 +138,7 @@ export function validateV1ConfigData(data) { }); // next filter out urls that are still invalid so all is not lost - teams = teams.filter(({url}) => Utils.isValidURL(url)); + teams = teams.filter(({url}) => urlUtils.isValidURL(url)); // replace original teams data.teams = teams; @@ -158,7 +158,7 @@ export function validateV2ConfigData(data) { }); // next filter out urls that are still invalid so all is not lost - teams = teams.filter(({url}) => Utils.isValidURL(url)); + teams = teams.filter(({url}) => urlUtils.isValidURL(url)); // replace original teams data.teams = teams; diff --git a/src/main/certificateStore.js b/src/main/certificateStore.js index ce4d0767..b0d06f6e 100644 --- a/src/main/certificateStore.js +++ b/src/main/certificateStore.js @@ -5,6 +5,8 @@ import fs from 'fs'; +import urlUtils from '../utils/url'; + import * as Validator from './Validator'; function comparableCertificate(certificate) { @@ -24,11 +26,6 @@ function areEqual(certificate0, certificate1) { return true; } -function getHost(targetURL) { - const parsedURL = new URL(targetURL); - return parsedURL.origin; -} - function CertificateStore(storeFile) { this.storeFile = storeFile; let storeStr; @@ -49,15 +46,15 @@ CertificateStore.prototype.save = function save() { }; CertificateStore.prototype.add = function add(targetURL, certificate) { - this.data[getHost(targetURL)] = comparableCertificate(certificate); + this.data[urlUtils.getHost(targetURL)] = comparableCertificate(certificate); }; CertificateStore.prototype.isExisting = function isExisting(targetURL) { - return this.data.hasOwnProperty(getHost(targetURL)); + return this.data.hasOwnProperty(urlUtils.getHost(targetURL)); }; CertificateStore.prototype.isTrusted = function isTrusted(targetURL, certificate) { - const host = getHost(targetURL); + const host = urlUtils.getHost(targetURL); if (!this.isExisting(targetURL)) { return false; } diff --git a/src/main/trustedOrigins.js b/src/main/trustedOrigins.js index 64bbcbb5..afe0ece9 100644 --- a/src/main/trustedOrigins.js +++ b/src/main/trustedOrigins.js @@ -7,7 +7,7 @@ import fs from 'fs'; import log from 'electron-log'; -import Utils from '../utils/util.js'; +import urlUtils from '../utils/url'; import * as Validator from './Validator'; @@ -56,12 +56,12 @@ export default class TrustedOriginsStore { if (!validPermissions) { throw new Error(`Invalid permissions set for trusting ${targetURL}`); } - this.data.set(Utils.getHost(targetURL), validPermissions); + this.data.set(urlUtils.getHost(targetURL), validPermissions); }; // enables usage of `targetURL` for `permission` addPermission = (targetURL, permission) => { - const origin = Utils.getHost(targetURL); + const origin = urlUtils.getHost(targetURL); const currentPermissions = this.data.get(origin) || {}; currentPermissions[permission] = true; this.set(origin, currentPermissions); @@ -70,7 +70,7 @@ export default class TrustedOriginsStore { delete = (targetURL) => { let host; try { - host = Utils.getHost(targetURL); + host = urlUtils.getHost(targetURL); this.data.delete(host); } catch { return false; @@ -79,7 +79,7 @@ export default class TrustedOriginsStore { } isExisting = (targetURL) => { - return (typeof this.data.get(Utils.getHost(targetURL)) !== 'undefined'); + return (typeof this.data.get(urlUtils.getHost(targetURL)) !== 'undefined'); }; // if user hasn't set his preferences, it will return null (falsy) @@ -90,7 +90,7 @@ export default class TrustedOriginsStore { } let origin; try { - origin = Utils.getHost(targetURL); + origin = urlUtils.getHost(targetURL); } catch (e) { log.error(`invalid host to retrieve permissions: ${targetURL}: ${e}`); return null; diff --git a/src/utils/url.js b/src/utils/url.js new file mode 100644 index 00000000..dfd177cf --- /dev/null +++ b/src/utils/url.js @@ -0,0 +1,190 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {isHttpsUri, isHttpUri, isUri} from 'valid-url'; + +import buildConfig from '../common/config/buildConfig'; + +function getDomain(inputURL) { + const parsedURL = parseURL(inputURL); + return parsedURL.origin; +} + +function isValidURL(testURL) { + return Boolean(isHttpUri(testURL) || isHttpsUri(testURL)) && parseURL(testURL) !== null; +} + +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; + } +} + +function getHost(inputURL) { + const parsedURL = parseURL(inputURL); + if (parsedURL) { + return parsedURL.origin; + } + throw new Error(`Couldn't parse url: ${inputURL}`); +} + +// 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 +function isInternalURL(targetURL, currentURL, basename = '/') { + if (targetURL.host !== currentURL.host) { + return false; + } + + if (!(targetURL.pathname || '/').startsWith(basename)) { + return false; + } + + 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 getManagedResources() { + if (!buildConfig) { + return []; + } + + return buildConfig.managedResources || []; +} + +function isAdminUrl(serverUrl, inputUrl) { + const parsedURL = parseURL(inputUrl); + const server = getServerInfo(serverUrl); + if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { + return null; + } + return (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}/admin_console/`) || + parsedURL.pathname.toLowerCase().startsWith('/admin_console/')); +} + +function isTeamUrl(serverUrl, inputUrl, withApi) { + const parsedURL = parseURL(inputUrl); + const server = getServerInfo(serverUrl); + if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { + return null; + } + + // pre process nonTeamUrlPaths + let nonTeamUrlPaths = [ + 'plugins', + 'signup', + 'login', + 'admin', + 'channel', + 'post', + 'oauth', + 'admin_console', + ]; + const managedResources = getManagedResources(); + nonTeamUrlPaths = nonTeamUrlPaths.concat(managedResources); + + if (withApi) { + nonTeamUrlPaths.push('api'); + } + return !(nonTeamUrlPaths.some((testPath) => ( + parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${testPath}/`) || + parsedURL.pathname.toLowerCase().startsWith(`/${testPath}/`)))); +} + +function isPluginUrl(serverUrl, inputURL) { + const server = getServerInfo(serverUrl); + const parsedURL = parseURL(inputURL); + if (!parsedURL || !server) { + return false; + } + return ( + equalUrlsIgnoringSubpath(server, parsedURL) && + (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}plugins/`) || + parsedURL.pathname.toLowerCase().startsWith('/plugins/'))); +} + +function isManagedResource(serverUrl, inputURL) { + const server = getServerInfo(serverUrl); + const parsedURL = parseURL(inputURL); + if (!parsedURL || !server) { + return false; + } + + const managedResources = getManagedResources(); + + return ( + equalUrlsIgnoringSubpath(server, parsedURL) && managedResources && managedResources.length && + managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`)))); +} + +function getServer(inputURL, teams) { + const parsedURL = parseURL(inputURL); + if (!parsedURL) { + return null; + } + let parsedServerUrl; + let secondOption = null; + 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 (equalUrlsWithSubpath(parsedServerUrl, parsedURL)) { + return {name: teams[i].name, url: parsedServerUrl, index: i}; + } + if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL)) { + // in case the user added something on the path that doesn't really belong to the server + // there might be more than one that matches, but we can't differentiate, so last one + // is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin) + // e.g.: https://community.mattermost.com/core + secondOption = {name: teams[i].name, url: parsedServerUrl, index: i}; + } + } + return secondOption; +} + +// next two functions are defined to clarify intent +function equalUrlsWithSubpath(url1, url2) { + return url1.origin === url2.origin && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase()); +} + +function equalUrlsIgnoringSubpath(url1, url2) { + return url1.origin.toLowerCase() === url2.origin.toLowerCase(); +} + +export default { + getDomain, + isValidURL, + isValidURI, + isInternalURL, + parseURL, + getServer, + getServerInfo, + isAdminUrl, + isTeamUrl, + isPluginUrl, + isManagedResource, + getHost, +}; diff --git a/src/utils/util.js b/src/utils/util.js index ee75e102..5f262693 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -1,173 +1,9 @@ // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import url from 'url'; import electron, {remote} from 'electron'; import log from 'electron-log'; -import {isUri, isHttpUri, isHttpsUri} from 'valid-url'; - -import buildConfig from '../common/config/buildConfig'; - -function getDomain(inputURL) { - const parsedURL = url.parse(inputURL); - return `${parsedURL.protocol}//${parsedURL.host}`; -} - -function isValidURL(testURL) { - return Boolean(isHttpUri(testURL) || isHttpsUri(testURL)) && parseURL(testURL) !== null; -} - -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; - } -} - -function getHost(inputURL) { - const parsedURL = parseURL(inputURL); - if (parsedURL) { - return parsedURL.origin; - } - throw new Error(`Couldn't parse url: ${inputURL}`); -} - -// 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 -function isInternalURL(targetURL, currentURL, basename = '/') { - if (targetURL.host !== currentURL.host) { - return false; - } - - if (!(targetURL.pathname || '/').startsWith(basename)) { - return false; - } - - 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 getManagedResources() { - if (!buildConfig) { - return []; - } - - return buildConfig.managedResources || []; -} - -function isAdminUrl(serverUrl, inputUrl) { - const parsedURL = parseURL(inputUrl); - const server = getServerInfo(serverUrl); - if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { - return null; - } - return (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}/admin_console/`) || - parsedURL.pathname.toLowerCase().startsWith('/admin_console/')); -} - -function isTeamUrl(serverUrl, inputUrl, withApi) { - const parsedURL = parseURL(inputUrl); - const server = getServerInfo(serverUrl); - if (!parsedURL || !server || (!equalUrlsIgnoringSubpath(server, parsedURL))) { - return null; - } - - // pre process nonTeamUrlPaths - let nonTeamUrlPaths = [ - 'plugins', - 'signup', - 'login', - 'admin', - 'channel', - 'post', - 'oauth', - 'admin_console', - ]; - const managedResources = getManagedResources(); - nonTeamUrlPaths = nonTeamUrlPaths.concat(managedResources); - - if (withApi) { - nonTeamUrlPaths.push('api'); - } - return !(nonTeamUrlPaths.some((testPath) => ( - parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${testPath}/`) || - parsedURL.pathname.toLowerCase().startsWith(`/${testPath}/`)))); -} - -function isPluginUrl(serverUrl, inputURL) { - const server = getServerInfo(serverUrl); - const parsedURL = parseURL(inputURL); - if (!parsedURL || !server) { - return false; - } - return ( - equalUrlsIgnoringSubpath(server, parsedURL) && - (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}plugins/`) || - parsedURL.pathname.toLowerCase().startsWith('/plugins/'))); -} - -function isManagedResource(serverUrl, inputURL) { - const server = getServerInfo(serverUrl); - const parsedURL = parseURL(inputURL); - if (!parsedURL || !server) { - return false; - } - - const managedResources = getManagedResources(); - - return ( - equalUrlsIgnoringSubpath(server, parsedURL) && managedResources && managedResources.length && - managedResources.some((managedResource) => (parsedURL.pathname.toLowerCase().startsWith(`${server.subpath}${managedResource}/`) || parsedURL.pathname.toLowerCase().startsWith(`/${managedResource}/`)))); -} - -function getServer(inputURL, teams) { - const parsedURL = parseURL(inputURL); - if (!parsedURL) { - return null; - } - let parsedServerUrl; - let secondOption = null; - 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 (equalUrlsWithSubpath(parsedServerUrl, parsedURL)) { - return {name: teams[i].name, url: parsedServerUrl, index: i}; - } - if (equalUrlsIgnoringSubpath(parsedServerUrl, parsedURL)) { - // in case the user added something on the path that doesn't really belong to the server - // there might be more than one that matches, but we can't differentiate, so last one - // is as good as any other in case there is no better match (e.g.: two subpath servers with the same origin) - // e.g.: https://community.mattermost.com/core - secondOption = {name: teams[i].name, url: parsedServerUrl, index: i}; - } - } - return secondOption; -} function getDisplayBoundaries() { const {screen} = electron; @@ -186,15 +22,6 @@ function getDisplayBoundaries() { }); } -// next two functions are defined to clarify intent -function equalUrlsWithSubpath(url1, url2) { - return url1.origin === url2.origin && url2.pathname.toLowerCase().startsWith(url1.pathname.toLowerCase()); -} - -function equalUrlsIgnoringSubpath(url1, url2) { - return url1.origin.toLowerCase() === url2.origin.toLowerCase(); -} - const dispatchNotification = async (title, body, silent, data, handleClick) => { let permission; const appIconURL = `file:///${remote.app.getAppPath()}/assets/appicon_48.png`; @@ -228,18 +55,6 @@ const dispatchNotification = async (title, body, silent, data, handleClick) => { }; export default { - getDomain, - isValidURL, - isValidURI, - isInternalURL, - parseURL, - getServer, - getServerInfo, - isAdminUrl, - isTeamUrl, - isPluginUrl, - isManagedResource, getDisplayBoundaries, dispatchNotification, - getHost, }; diff --git a/test/modules/utils.js b/test/modules/utils.js index 5d3b5073..53609ca3 100644 --- a/test/modules/utils.js +++ b/test/modules/utils.js @@ -1,4 +1,3 @@ - // Copyright (c) 2015-2016 Yuya Ochiai // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. diff --git a/test/specs/utils/util_test.js b/test/specs/utils/url_test.js similarity index 58% rename from test/specs/utils/util_test.js rename to test/specs/utils/url_test.js index ca4d8113..dd90d3d3 100644 --- a/test/specs/utils/util_test.js +++ b/test/specs/utils/url_test.js @@ -2,106 +2,105 @@ // See LICENSE.txt for license information. 'use strict'; -import url from 'url'; import assert from 'assert'; -import Utils from '../../../src/utils/util'; +import urlUtils from '../../../src/utils/url'; -describe('Utils', () => { +describe('url', () => { describe('isValidURL', () => { it('should be true for a valid web url', () => { const testURL = 'https://developers.mattermost.com/'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a valid, non-https web url', () => { const testURL = 'http://developers.mattermost.com/'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for an invalid, self-defined, top-level domain', () => { const testURL = 'https://www.example.x'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a file download url', () => { const testURL = 'https://community.mattermost.com/api/v4/files/ka3xbfmb3ffnmgdmww8otkidfw?download=1'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a permalink url', () => { const testURL = 'https://community.mattermost.com/test-channel/pl/pdqowkij47rmbyk78m5hwc7r6r'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a valid, internal domain', () => { const testURL = 'https://mattermost.company-internal'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a second, valid internal domain', () => { const testURL = 'https://serverXY/mattermost'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a valid, non-https internal domain', () => { const testURL = 'http://mattermost.local'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); it('should be true for a valid, non-https, ip address with port number', () => { const testURL = 'http://localhost:8065'; - assert.equal(Utils.isValidURL(testURL), true); + assert.equal(urlUtils.isValidURL(testURL), true); }); }); describe('isValidURI', () => { it('should be true for a deeplink url', () => { const testURL = 'mattermost://community-release.mattermost.com/core/channels/developers'; - assert.equal(Utils.isValidURI(testURL), true); + assert.equal(urlUtils.isValidURI(testURL), true); }); it('should be false for a malicious url', () => { const testURL = String.raw`mattermost:///" --data-dir "\\deans-mbp\mattermost`; - assert.equal(Utils.isValidURI(testURL), false); + assert.equal(urlUtils.isValidURI(testURL), false); }); }); describe('isInternalURL', () => { it('should be false for different hosts', () => { - const currentURL = url.parse('http://localhost/team/channel1'); - const targetURL = url.parse('http://example.com/team/channel2'); + const currentURL = new URL('http://localhost/team/channel1'); + const targetURL = new URL('http://example.com/team/channel2'); const basename = '/'; - assert.equal(Utils.isInternalURL(targetURL, currentURL, basename), false); + assert.equal(urlUtils.isInternalURL(targetURL, currentURL, basename), false); }); it('should be false for same hosts, non-matching basename', () => { - const currentURL = url.parse('http://localhost/subpath/team/channel1'); - const targetURL = url.parse('http://localhost/team/channel2'); + const currentURL = new URL('http://localhost/subpath/team/channel1'); + const targetURL = new URL('http://localhost/team/channel2'); const basename = '/subpath'; - assert.equal(Utils.isInternalURL(targetURL, currentURL, basename), false); + assert.equal(urlUtils.isInternalURL(targetURL, currentURL, basename), false); }); it('should be true for same hosts, matching basename', () => { - const currentURL = url.parse('http://localhost/subpath/team/channel1'); - const targetURL = url.parse('http://localhost/subpath/team/channel2'); + const currentURL = new URL('http://localhost/subpath/team/channel1'); + const targetURL = new URL('http://localhost/subpath/team/channel2'); const basename = '/subpath'; - assert.equal(Utils.isInternalURL(targetURL, currentURL, basename), true); + assert.equal(urlUtils.isInternalURL(targetURL, currentURL, basename), true); }); it('should be true for same hosts, default basename', () => { - const currentURL = url.parse('http://localhost/team/channel1'); - const targetURL = url.parse('http://localhost/team/channel2'); + const currentURL = new URL('http://localhost/team/channel1'); + const targetURL = new URL('http://localhost/team/channel2'); const basename = '/'; - assert.equal(Utils.isInternalURL(targetURL, currentURL, basename), true); + assert.equal(urlUtils.isInternalURL(targetURL, currentURL, basename), true); }); it('should be true for same hosts, default basename, empty target path', () => { - const currentURL = url.parse('http://localhost/team/channel1'); - const targetURL = url.parse('http://localhost/'); + const currentURL = new URL('http://localhost/team/channel1'); + const targetURL = new URL('http://localhost/'); const basename = '/'; - assert.equal(Utils.isInternalURL(targetURL, currentURL, basename), true); + assert.equal(urlUtils.isInternalURL(targetURL, currentURL, basename), true); }); }); describe('getHost', () => { it('should return the origin of a well formed url', () => { const myurl = 'https://mattermost.com/download'; - assert.equal(Utils.getHost(myurl), 'https://mattermost.com'); + assert.equal(urlUtils.getHost(myurl), 'https://mattermost.com'); }); it('shoud raise an error on malformed urls', () => { const myurl = 'http://example.com:-80/'; - assert.throws(() => Utils.getHost(myurl), Error); + assert.throws(() => urlUtils.getHost(myurl), Error); }); }); });