diff --git a/package-lock.json b/package-lock.json index bc19023f..e38e44fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5180,9 +5180,9 @@ } }, "eslint-plugin-eslint-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.1.1.tgz", - "integrity": "sha512-GZDKhOFqJLKlaABX+kdoLskcTINMrVOWxGca54KcFb1QCPd0CLmqgAMRxkkUfGSmN+5NJUMGh7NGccIMcWPSfQ==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.1.2.tgz", + "integrity": "sha512-QexaqrNeteFfRTad96W+Vi4Zj1KFbkHHNMMaHZEYcovKav6gdomyGzaxSDSL3GoIyUOo078wRAdYlu1caiauIQ==", "dev": true, "requires": { "escape-string-regexp": "^1.0.5", @@ -5190,9 +5190,9 @@ }, "dependencies": { "ignore": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.0.5.tgz", - "integrity": "sha512-kOC8IUb8HSDMVcYrDVezCxpJkzSQWTAzf3olpKM6o9rM5zpojx23O0Fl8Wr4+qJ6ZbPEHqf1fdwev/DS7v7pmA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz", + "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==", "dev": true } } @@ -6114,7 +6114,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -6135,12 +6136,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6155,17 +6158,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -6282,7 +6288,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -6294,6 +6301,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -6308,6 +6316,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -6315,12 +6324,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6339,6 +6350,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -6419,7 +6431,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -6431,6 +6444,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -6516,7 +6530,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -6552,6 +6567,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6571,6 +6587,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -6614,12 +6631,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, diff --git a/package.json b/package.json index ffe70963..d7223202 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "eslint": "^5.9.0", "eslint-config-mattermost": "github:mattermost/eslint-config-mattermost", "eslint-plugin-cypress": "^2.1.2", - "eslint-plugin-eslint-comments": "^3.1.1", + "eslint-plugin-eslint-comments": "^3.1.2", "eslint-plugin-header": "^2.0.0", "eslint-plugin-import": "^2.14.0", "eslint-plugin-react": "^7.11.1", diff --git a/src/browser/components/MainPage.jsx b/src/browser/components/MainPage.jsx index cdcefd4c..dd1815bb 100644 --- a/src/browser/components/MainPage.jsx +++ b/src/browser/components/MainPage.jsx @@ -14,8 +14,6 @@ import {Grid, Row} from 'react-bootstrap'; import {ipcRenderer, remote} from 'electron'; -import Utils from '../../utils/util.js'; - import LoginModal from './LoginModal.jsx'; import MattermostView from './MattermostView.jsx'; import TabBar from './TabBar.jsx'; @@ -30,11 +28,9 @@ export default class MainPage extends React.Component { let key = this.props.initialIndex; if (this.props.deeplinkingUrl !== null) { - for (let i = 0; i < this.props.teams.length; i++) { - if (this.props.deeplinkingUrl.includes(this.props.teams[i].url)) { - key = i; - break; - } + const parsedDeeplink = this.parseDeeplinkURL(this.props.deeplinkingUrl); + if (parsedDeeplink) { + key = parsedDeeplink.teamIndex; } } @@ -62,6 +58,27 @@ export default class MainPage extends React.Component { this.markReadAtActive = this.markReadAtActive.bind(this); } + parseDeeplinkURL(deeplink, teams = this.props.teams) { + if (deeplink && Array.isArray(teams) && teams.length) { + const deeplinkURL = url.parse(deeplink); + let parsedDeeplink = null; + teams.forEach((team, index) => { + const teamURL = url.parse(team.url); + if (deeplinkURL.host === teamURL.host) { + parsedDeeplink = { + teamURL, + teamIndex: index, + originalURL: deeplinkURL, + url: `${teamURL.protocol}//${teamURL.host}${deeplinkURL.pathname}`, + path: deeplinkURL.pathname, + }; + } + }); + return parsedDeeplink; + } + return null; + } + componentDidMount() { const self = this; ipcRenderer.on('login-request', (event, request, authInfo) => { @@ -141,15 +158,12 @@ export default class MainPage extends React.Component { }); ipcRenderer.on('protocol-deeplink', (event, deepLinkUrl) => { - const lastUrlDomain = Utils.getDomain(deepLinkUrl); - for (let i = 0; i < this.props.teams.length; i++) { - if (lastUrlDomain === Utils.getDomain(self.refs[`mattermostView${i}`].getSrc())) { - if (this.state.key !== i) { - this.handleSelect(i); - } - self.refs[`mattermostView${i}`].handleDeepLink(deepLinkUrl.replace(lastUrlDomain, '')); - break; + const parsedDeeplink = this.parseDeeplinkURL(deepLinkUrl); + if (parsedDeeplink) { + if (this.state.key !== parsedDeeplink.teamIndex) { + this.handleSelect(parsedDeeplink.teamIndex); } + self.refs[`mattermostView${parsedDeeplink.teamIndex}`].handleDeepLink(parsedDeeplink.path); } }); @@ -337,9 +351,12 @@ export default class MainPage extends React.Component { const isActive = self.state.key === index; let teamUrl = team.url; - const deeplinkingUrl = this.props.deeplinkingUrl; - if (deeplinkingUrl !== null && deeplinkingUrl.includes(teamUrl)) { - teamUrl = deeplinkingUrl; + + if (this.props.deeplinkingUrl) { + const parsedDeeplink = this.parseDeeplinkURL(this.props.deeplinkingUrl, [team]); + if (parsedDeeplink) { + teamUrl = parsedDeeplink.url; + } } return ( diff --git a/src/browser/components/MattermostView.jsx b/src/browser/components/MattermostView.jsx index 16ad0689..50b4ab20 100644 --- a/src/browser/components/MattermostView.jsx +++ b/src/browser/components/MattermostView.jsx @@ -90,6 +90,9 @@ export default class MattermostView extends React.Component { // Open link in browserWindow. for example, attached files. webview.addEventListener('new-window', (e) => { + if (!Utils.isValidURL(e.url)) { + return; + } const currentURL = url.parse(webview.getURL()); const destURL = url.parse(e.url); if (destURL.protocol !== 'http:' && destURL.protocol !== 'https:' && destURL.protocol !== `${scheme}:`) { diff --git a/src/browser/components/NewTeamModal.jsx b/src/browser/components/NewTeamModal.jsx index 819a5bb2..d5d1deea 100644 --- a/src/browser/components/NewTeamModal.jsx +++ b/src/browser/components/NewTeamModal.jsx @@ -6,6 +6,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {Modal, Button, FormGroup, FormControl, ControlLabel, HelpBlock} from 'react-bootstrap'; +import Utils from '../../utils/util'; + export default class NewTeamModal extends React.Component { constructor() { super(); @@ -54,6 +56,9 @@ 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())) { + return 'URL is not formatted correctly.'; + } return null; } diff --git a/src/common/config/defaultPreferences.js b/src/common/config/defaultPreferences.js index 8a26665f..76b95a5c 100644 --- a/src/common/config/defaultPreferences.js +++ b/src/common/config/defaultPreferences.js @@ -21,6 +21,7 @@ const defaultPreferences = { useSpellChecker: true, enableHardwareAcceleration: true, autostart: true, + spellCheckerLocale: 'en-US', }; export default defaultPreferences; diff --git a/src/common/config/index.js b/src/common/config/index.js index f1757019..67a93777 100644 --- a/src/common/config/index.js +++ b/src/common/config/index.js @@ -7,6 +7,8 @@ import path from 'path'; import {EventEmitter} from 'events'; +import * as Validator from '../../main/Validator'; + import defaultPreferences from './defaultPreferences'; import upgradeConfigData from './upgradePreferences'; import buildConfig from './buildConfig'; @@ -205,6 +207,18 @@ export default class Config extends EventEmitter { let configData = {}; try { configData = this.readFileSync(this.configFilePath); + + // validate based on config file version + switch (configData.version) { + case 1: + configData = Validator.validateV1ConfigData(configData); + break; + default: + configData = Validator.validateV0ConfigData(configData); + } + if (!configData) { + throw new Error('Provided configuration file does not validate, using defaults instead.'); + } } catch (e) { console.log('Failed to load configuration file from the filesystem. Using defaults.'); configData = this.copy(this.defaultConfigData); diff --git a/src/main.js b/src/main.js index 824602a5..e3e9d77d 100644 --- a/src/main.js +++ b/src/main.js @@ -10,7 +10,6 @@ import {URL} from 'url'; import electron from 'electron'; import isDev from 'electron-is-dev'; import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; -import {parse as parseArgv} from 'yargs'; import {protocols} from '../electron-builder.json'; @@ -33,6 +32,8 @@ import initCookieManager from './main/cookieManager'; import {shouldBeHiddenOnStartup} from './main/utils'; import SpellChecker from './main/SpellChecker'; import UserActivityMonitor from './main/UserActivityMonitor'; +import Utils from './utils/util'; +import parseArgs from './main/ParseArgs'; // pull out required electron components like this // as not all components can be referenced before the app is ready @@ -48,15 +49,14 @@ const { } = electron; const criticalErrorHandler = new CriticalErrorHandler(); const assetsDir = path.resolve(app.getAppPath(), 'assets'); -const argv = parseArgv(process.argv.slice(1)); -const hideOnStartup = shouldBeHiddenOnStartup(argv); const loginCallbackMap = new Map(); -const certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json')); const userActivityMonitor = new UserActivityMonitor(); // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let mainWindow = null; +let hideOnStartup = null; +let certificateStore = null; let spellChecker = null; let deeplinkingUrl = null; let scheme = null; @@ -74,15 +74,9 @@ async function initialize() { process.on('uncaughtException', criticalErrorHandler.processUncaughtExceptionHandler.bind(criticalErrorHandler)); global.willAppQuit = false; - global.isDev = isDev && !argv.disableDevMode; - - app.setAppUserModelId('com.squirrel.mattermost.Mattermost'); // Use explicit AppUserModelID - - if (argv['data-dir']) { - app.setPath('userData', path.resolve(argv['data-dir'])); - } // initialization that can run before the app is ready + initializeArgs(); initializeConfig(); initializeAppEventListeners(); initializeBeforeAppReady(); @@ -115,6 +109,24 @@ try { // initialization sub functions // +function initializeArgs() { + global.args = parseArgs(process.argv.slice(1)); + + // output the application version via cli when requested (-v or --version) + if (global.args.version) { + process.stdout.write(`v.${app.getVersion()}\n`); + process.exit(0); // eslint-disable-line no-process-exit + } + + hideOnStartup = shouldBeHiddenOnStartup(global.args); + + global.isDev = isDev && !global.args.disableDevMode; // this doesn't seem to be right and isn't used as the single source of truth + + if (global.args['data-dir']) { + app.setPath('userData', path.resolve(global.args['data-dir'])); + } +} + function initializeConfig() { registryConfig = new RegistryConfig(); config = new Config(app.getPath('userData') + '/config.json'); @@ -136,6 +148,8 @@ function initializeAppEventListeners() { } function initializeBeforeAppReady() { + certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json')); + // can only call this before the app is ready if (config.enableHardwareAcceleration === false) { app.disableHardwareAcceleration(); @@ -179,7 +193,7 @@ function initializeInterCommunicationEventListeners() { if (shouldShowTrayIcon()) { ipcMain.on('update-unread', handleUpdateUnreadEvent); } - if (config.enableAutoUpdater) { + if (!isDev && config.enableAutoUpdater) { ipcMain.on('check-for-updates', autoUpdater.checkForUpdates); } } @@ -189,7 +203,7 @@ function initializeMainWindowListeners() { mainWindow.on('unresponsive', criticalErrorHandler.windowUnresponsiveHandler.bind(criticalErrorHandler)); mainWindow.webContents.on('crashed', handleMainWindowWebContentsCrashed); mainWindow.on('ready-to-show', handleMainWindowReadyToShow); - if (config.enableAutoUpdater) { + if (!isDev && config.enableAutoUpdater) { mainWindow.once('show', handleMainWindowShow); } } @@ -233,13 +247,12 @@ function handleReloadConfig() { // // activate first app instance, subsequent instances will quit themselves -function handleAppSecondInstance(event, secondArgv) { +function handleAppSecondInstance(event, argv) { // Protocol handler for win32 // argv: An array of the second instance’s (command line / deep linked) arguments if (process.platform === 'win32') { - // Keep only command line / deep linked arguments - if (Array.isArray(secondArgv.slice(1)) && secondArgv.slice(1).length > 0) { - setDeeplinkingUrl(secondArgv.slice(1)[0]); + deeplinkingUrl = getDeeplinkingURL(argv); + if (deeplinkingUrl) { mainWindow.webContents.send('protocol-deeplink', deeplinkingUrl); } } @@ -337,12 +350,14 @@ function handleAppWillFinishLaunching() { // Protocol handler for osx app.on('open-url', (event, url) => { event.preventDefault(); - setDeeplinkingUrl(url); + deeplinkingUrl = getDeeplinkingURL([url]); if (app.isReady()) { function openDeepLink() { try { - mainWindow.webContents.send('protocol-deeplink', deeplinkingUrl); - mainWindow.show(); + if (deeplinkingUrl) { + mainWindow.webContents.send('protocol-deeplink', deeplinkingUrl); + mainWindow.show(); + } } catch (err) { setTimeout(openDeepLink, 1000); } @@ -379,6 +394,8 @@ function handleAppWebContentsCreated(dc, contents) { } function initializeAfterAppReady() { + app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID + const appStateJson = path.join(app.getPath('userData'), 'app-state.json'); appState = new AppStateManager(appStateJson); if (wasUpdated(appState.lastAppVersion)) { @@ -398,13 +415,9 @@ function initializeAfterAppReady() { // Protocol handler for win32 if (process.platform === 'win32') { - // Keep only command line / deep linked argument. Make sure it's not squirrel command - const tmpArgs = process.argv.slice(1); - if ( - Array.isArray(tmpArgs) && tmpArgs.length > 0 && - tmpArgs[0].match(/^--squirrel-/) === null - ) { - setDeeplinkingUrl(tmpArgs[0]); + const args = process.argv.slice(1); + if (Array.isArray(args) && args.length > 0) { + deeplinkingUrl = getDeeplinkingURL(args); } } @@ -502,7 +515,7 @@ function initializeAfterAppReady() { permissionManager = new PermissionManager(permissionFile, trustedURLs); session.defaultSession.setPermissionRequestHandler(permissionRequestHandler(mainWindow, permissionManager)); - if (config.enableAutoUpdater) { + if (!isDev && config.enableAutoUpdater) { const updaterConfig = autoUpdater.loadConfig(); autoUpdater.initialize(appState, mainWindow, updaterConfig.isNotifyOnly()); ipcMain.on('check-for-updates', autoUpdater.checkForUpdates); @@ -680,16 +693,20 @@ function handleMainWindowWebContentsCrashed() { } function handleMainWindowReadyToShow() { - autoUpdater.checkForUpdates(); + if (!isDev) { + autoUpdater.checkForUpdates(); + } } function handleMainWindowShow() { - if (autoUpdater.shouldCheckForUpdatesOnStart(appState.updateCheckedDate)) { - ipcMain.emit('check-for-updates'); - } else { - setTimeout(() => { + if (!isDev) { + if (autoUpdater.shouldCheckForUpdatesOnStart(appState.updateCheckedDate)) { ipcMain.emit('check-for-updates'); - }, autoUpdater.UPDATER_INTERVAL_IN_MS); + } else { + setTimeout(() => { + ipcMain.emit('check-for-updates'); + }, autoUpdater.UPDATER_INTERVAL_IN_MS); + } } } @@ -757,10 +774,15 @@ function switchMenuIconImages(icons, isDarkMode) { } } -function setDeeplinkingUrl(url) { - if (scheme) { - deeplinkingUrl = url.replace(new RegExp('^' + scheme), 'https'); +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)) { + return url; + } } + return null; } function shouldShowTrayIcon() { diff --git a/src/main/AppStateManager.js b/src/main/AppStateManager.js index 0e4dec0f..11c7a210 100644 --- a/src/main/AppStateManager.js +++ b/src/main/AppStateManager.js @@ -3,7 +3,18 @@ // See LICENSE.txt for license information. import JsonFileManager from '../common/JsonFileManager'; +import * as Validator from './Validator'; + export default class AppStateManager extends JsonFileManager { + constructor(file) { + super(file); + + // ensure data loaded from file is valid + const validatedJSON = Validator.validateAppState(this.json); + if (!validatedJSON) { + this.setJson({}); + } + } set lastAppVersion(version) { this.setValue('lastAppVersion', version); } diff --git a/src/main/ParseArgs.js b/src/main/ParseArgs.js new file mode 100644 index 00000000..1e061a06 --- /dev/null +++ b/src/main/ParseArgs.js @@ -0,0 +1,37 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import yargs from 'yargs'; + +import {protocols} from '../../electron-builder.json'; + +import * as Validator from './Validator'; + +export default function parse(args) { + return validateArgs(parseArgs(triageArgs(args))); +} + +function triageArgs(args) { + // ensure any args following a possible deeplink are discarded + if (protocols && protocols[0] && protocols[0].schemes && protocols[0].schemes[0]) { + const scheme = protocols[0].schemes[0].toLowerCase(); + const deeplinkIndex = args.findIndex((arg) => arg.toLowerCase().includes(`${scheme}:`)); + if (deeplinkIndex !== -1) { + return args.slice(0, deeplinkIndex + 1); + } + } + return args; +} + +function parseArgs(args) { + return yargs. + boolean('hidden').describe('hidden', 'Launch the app in hidden mode.'). + alias('disable-dev-mode', 'disableDevMode').boolean('disable-dev-mode').describe('disable-dev-mode', 'Disable dev mode.'). + alias('data-dir', 'dataDir').string('data-dir').describe('data-dir', 'Set the path to where user data is stored.'). + alias('v', 'version').boolean('v').describe('version', 'Prints the application version.'). + parse(args); +} + +function validateArgs(args) { + return Validator.validateArgs(args) || {}; +} diff --git a/src/main/PermissionManager.js b/src/main/PermissionManager.js index 735b3c74..195834ac 100644 --- a/src/main/PermissionManager.js +++ b/src/main/PermissionManager.js @@ -5,6 +5,8 @@ import fs from 'fs'; import utils from '../utils/util'; +import * as Validator from './Validator'; + const PERMISSION_GRANTED = 'granted'; const PERMISSION_DENIED = 'denied'; @@ -15,6 +17,10 @@ export default class PermissionManager { if (fs.existsSync(file)) { try { this.permissions = JSON.parse(fs.readFileSync(this.file, 'utf-8')); + this.permissions = Validator.validatePermissionsList(this.permissions); + if (!this.permissions) { + throw new Error('Provided permissions file does not validate, using defaults instead.'); + } } catch (err) { console.error(err); this.permissions = {}; diff --git a/src/main/Validator.js b/src/main/Validator.js new file mode 100644 index 00000000..3895a5cb --- /dev/null +++ b/src/main/Validator.js @@ -0,0 +1,151 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import Joi from '@hapi/joi'; + +import Utils from '../utils/util'; + +const defaultOptions = { + stripUnknown: true, +}; + +const defaultWindowWidth = 1000; +const defaultWindowHeight = 700; +const minWindowWidth = 400; +const minWindowHeight = 240; + +const argsSchema = Joi.object({ + hidden: Joi.boolean(), + 'disable-dev-mode': Joi.boolean(), + disableDevMode: Joi.boolean(), + 'data-dir': Joi.string(), + dataDir: Joi.array().items(Joi.string()), + version: Joi.boolean(), +}); + +const boundsInfoSchema = Joi.object({ + x: Joi.number().integer().min(0), + y: Joi.number().integer().min(0), + width: Joi.number().integer().min(minWindowWidth).required().default(defaultWindowWidth), + height: Joi.number().integer().min(minWindowHeight).required().default(defaultWindowHeight), + maximized: Joi.boolean().default(false), + fullscreen: Joi.boolean().default(false), +}); + +const appStateSchema = Joi.object({ + lastAppVersion: Joi.string(), + skippedVersion: Joi.string(), + updateCheckedDate: Joi.string(), +}); + +const configDataSchemaV0 = Joi.object({ + url: Joi.string().required(), +}); + +const configDataSchemaV1 = Joi.object({ + version: Joi.number().min(1).default(1), + teams: Joi.array().items(Joi.object({ + name: Joi.string().required(), + url: Joi.string().required(), + })).default([]), + showTrayIcon: Joi.boolean().default(false), + trayIconTheme: Joi.any().allow('').valid('light', 'dark').default('light'), + minimizeToTray: Joi.boolean().default(false), + notifications: Joi.object({ + flashWindow: Joi.any().valid(0, 2).default(0), + bounceIcon: Joi.boolean().default(false), + bounceIconType: Joi.any().allow('').valid('informational', 'critical').default('informational'), + }), + showUnreadBadge: Joi.boolean().default(true), + useSpellChecker: Joi.boolean().default(true), + enableHardwareAcceleration: Joi.boolean().default(true), + autostart: Joi.boolean().default(true), + spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'), +}); + +// eg. data['https://community.mattermost.com']['notifications'] = 'granted'; +// eg. data['http://localhost:8065']['notifications'] = 'denied'; +const permissionsSchema = Joi.object().pattern( + Joi.string().uri(), + Joi.object().pattern( + Joi.string(), + Joi.any().valid('granted', 'denied'), + ), +); + +// eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'}; +const certificateStoreSchema = Joi.object().pattern( + Joi.string().uri(), + Joi.object({ + data: Joi.string(), + issuerName: Joi.string(), + }) +); + +const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i)); + +// validate bounds_info.json +export function validateArgs(data) { + return validateAgainstSchema(data, argsSchema); +} + +// validate bounds_info.json +export function validateBoundsInfo(data) { + return validateAgainstSchema(data, boundsInfoSchema); +} + +// validate app_state.json +export function validateAppState(data) { + return validateAgainstSchema(data, appStateSchema); +} + +// validate v.0 config.json +export function validateV0ConfigData(data) { + return validateAgainstSchema(data, configDataSchemaV0); +} + +// validate v.1 config.json +export function validateV1ConfigData(data) { + if (Array.isArray(data.teams) && data.teams.length) { + // first replace possible backslashes with forward slashes + let teams = data.teams.map(({name, url}) => { + let updatedURL = url; + if (updatedURL.includes('\\')) { + updatedURL = updatedURL.toLowerCase().replace(/\\/gi, '/'); + } + return {name, url: updatedURL}; + }); + + // next filter out urls that are still invalid so all is not lost + teams = teams.filter(({url}) => Utils.isValidURL(url)); + + // replace original teams + data.teams = teams; + } + return validateAgainstSchema(data, configDataSchemaV1); +} + +// validate permission.json +export function validatePermissionsList(data) { + return validateAgainstSchema(data, permissionsSchema); +} + +// validate certificate.json +export function validateCertificateStore(data) { + return validateAgainstSchema(data, certificateStoreSchema); +} + +// validate allowedProtocols.json +export function validateAllowedProtocols(data) { + return validateAgainstSchema(data, allowedProtocolsSchema); +} + +function validateAgainstSchema(data, schema) { + if (typeof data !== 'object' || !schema) { + return false; + } + const {error, value} = Joi.validate(data, schema, defaultOptions); + if (error) { + return false; + } + return value; +} diff --git a/src/main/allowProtocolDialog.js b/src/main/allowProtocolDialog.js index 43aea5d6..de49168c 100644 --- a/src/main/allowProtocolDialog.js +++ b/src/main/allowProtocolDialog.js @@ -8,6 +8,8 @@ import fs from 'fs'; import {app, dialog, ipcMain, shell} from 'electron'; +import * as Validator from './Validator'; + const allowedProtocolFile = path.resolve(app.getPath('userData'), 'allowedProtocols.json'); let allowedProtocols = []; @@ -15,6 +17,7 @@ function init(mainWindow) { fs.readFile(allowedProtocolFile, 'utf-8', (err, data) => { if (!err) { allowedProtocols = JSON.parse(data); + allowedProtocols = Validator.validateAllowedProtocols(allowedProtocols) || []; } initDialogEvent(mainWindow); }); diff --git a/src/main/certificateStore.js b/src/main/certificateStore.js index 051f35ad..1398cd63 100644 --- a/src/main/certificateStore.js +++ b/src/main/certificateStore.js @@ -6,6 +6,8 @@ import fs from 'fs'; import url from 'url'; +import * as Validator from './Validator'; + function comparableCertificate(certificate) { return { data: certificate.data.toString(), @@ -32,6 +34,10 @@ function CertificateStore(storeFile) { let storeStr; try { storeStr = fs.readFileSync(storeFile, 'utf-8'); + storeStr = Validator.validateCertificateStore(storeStr); + if (!storeStr) { + throw new Error('Provided certificate store file does not validate, using defaults instead.'); + } } catch (e) { storeStr = '{}'; } diff --git a/src/main/mainWindow.js b/src/main/mainWindow.js index df02e923..cd40acf4 100644 --- a/src/main/mainWindow.js +++ b/src/main/mainWindow.js @@ -6,6 +6,8 @@ import path from 'path'; import {app, BrowserWindow} from 'electron'; +import * as Validator from './Validator'; + function saveWindowState(file, window) { const windowState = window.getBounds(); windowState.maximized = window.isMaximized(); @@ -28,6 +30,10 @@ function createMainWindow(config, options) { let windowOptions; try { windowOptions = JSON.parse(fs.readFileSync(boundsInfoPath, 'utf-8')); + windowOptions = Validator.validateBoundsInfo(windowOptions); + if (!windowOptions) { + throw new Error('Provided bounds info file does not validate, using defaults instead.'); + } } catch (e) { // Follow Electron's defaults, except for window dimensions which targets 1024x768 screen resolution. windowOptions = {width: defaultWindowWidth, height: defaultWindowHeight}; diff --git a/src/main/squirrelStartup.js b/src/main/squirrelStartup.js deleted file mode 100644 index a78b9195..00000000 --- a/src/main/squirrelStartup.js +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2015-2016 Yuya Ochiai -// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import AutoLaunch from 'auto-launch'; -import {app} from 'electron'; - -function shouldQuitApp(cmd) { - if (process.platform !== 'win32') { - return false; - } - const squirrelCommands = ['--squirrel-install', '--squirrel-updated', '--squirrel-uninstall', '--squirrel-obsolete']; - return squirrelCommands.includes(cmd); -} - -async function setupAutoLaunch(cmd) { - const appLauncher = new AutoLaunch({ - name: app.getName(), - isHidden: true, - }); - if (cmd === '--squirrel-uninstall') { - // If we're uninstalling, make sure we also delete our auto launch registry key - await appLauncher.disable(); - } else if (cmd === '--squirrel-install' || cmd === '--squirrel-updated') { - // If we're updating and already have an registry entry for auto launch, make sure to update the path - const enabled = await appLauncher.isEnabled(); - if (enabled) { - await appLauncher.enable(); - } - } -} - -export default function squirrelStartup(callback) { - if (process.platform === 'win32') { - const cmd = process.argv[1]; - setupAutoLaunch(cmd).then(() => { - if (require('electron-squirrel-startup') && callback) { // eslint-disable-line global-require - callback(); - } - }); - return shouldQuitApp(cmd); - } - return false; -} diff --git a/src/package-lock.json b/src/package-lock.json index 45cfd1e4..ee1f6aea 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -26,6 +26,47 @@ "regenerator-runtime": "^0.12.0" } }, + "@hapi/address": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", + "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==" + }, + "@hapi/hoek": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", + "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==" + }, + "@hapi/joi": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", + "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", + "requires": { + "@hapi/address": "2.x.x", + "@hapi/hoek": "6.x.x", + "@hapi/marker": "1.x.x", + "@hapi/topo": "3.x.x" + } + }, + "@hapi/marker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", + "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==" + }, + "@hapi/topo": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.2.tgz", + "integrity": "sha512-r+aumOqJ5QbD6aLPJWqVjMAPsx5pZKz+F5yPqXZ/WWG9JTtHbQqlzrJoknJ0iJxLj9vlXtmpSdjlkszseeG8OA==", + "requires": { + "@hapi/hoek": "8.x.x" + }, + "dependencies": { + "@hapi/hoek": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.0.2.tgz", + "integrity": "sha512-O6o6mrV4P65vVccxymuruucb+GhP2zl9NLCG8OdoFRS8BEGw3vwpPp20wpAtpbQQxz1CEUtmxJGgWhjq1XA3qw==" + } + } + }, "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", @@ -171,6 +212,10 @@ "resolved": "https://registry.npmjs.org/cross-unzip/-/cross-unzip-0.0.2.tgz", "integrity": "sha1-UYO8R6CVWb78+YzEZXlkmZNZNy8=" }, + "damerau-levenshtein": { + "version": "git://github.com/cbaatz/damerau-levenshtein.git#8d7440a51ae9dc6912e44385115c7cb1da4e8ebc", + "from": "git://github.com/cbaatz/damerau-levenshtein.git" + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -228,9 +273,9 @@ "integrity": "sha512-iwM3EotA9HTXqMGpQRkR/kT8OZqBbdfHTnlwcxsjSLYqY8svvsq0MuujsWCn3/vtgRmDv/PC/gKUUpoZvi5C1w==" }, "electron-log": { - "version": "2.2.15", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-2.2.15.tgz", - "integrity": "sha512-lpTQmU9ZjTtTkg2hHEQCvnrkrqLwvhfnMCXPhjSWA5sFXvybGMn13M0xU3CbvVbZuHSFZww6t7HyWGt+Tnye0g==" + "version": "2.2.17", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-2.2.17.tgz", + "integrity": "sha512-v+Af5W5z99ehhaLOfE9eTSXUwjzh2wFlQjz51dvkZ6ZIrET6OB/zAZPvsuwT6tm3t5x+M1r+Ed3U3xtPZYAyuQ==" }, "electron-updater": { "version": "4.0.6", @@ -638,14 +683,9 @@ "integrity": "sha512-RGgpBBn4p65nOhZdBHDt84Remz3QvmoQDppKJX0tYS53SbuUJwm7zG1swT5O770zPvN6wZaZlc8ie/jwlZbeHA==", "requires": { "binarysearch": "^0.2.4", + "damerau-levenshtein": "git://github.com/cbaatz/damerau-levenshtein.git", "strip-bom": "^2.0.0", "unzip-stream": "^0.3.0" - }, - "dependencies": { - "damerau-levenshtein": { - "version": "git://github.com/cbaatz/damerau-levenshtein.git#8d7440a51ae9dc6912e44385115c7cb1da4e8ebc", - "from": "git://github.com/cbaatz/damerau-levenshtein.git#8d7440a51ae9dc6912e44385115c7cb1da4e8ebc" - } } }, "sort-keys": { @@ -755,6 +795,11 @@ "mkdirp": "^0.5.1" } }, + "valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA=" + }, "warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", diff --git a/src/package.json b/src/package.json index 453725dd..e713bc5b 100644 --- a/src/package.json +++ b/src/package.json @@ -9,12 +9,13 @@ "homepage": "https://about.mattermost.com", "license": "Apache-2.0", "dependencies": { + "@hapi/joi": "^15.1.0", "auto-launch": "^5.0.5", "bootstrap": "^3.3.7", "electron-context-menu": "^0.10.1", "electron-devtools-installer": "^2.2.4", "electron-is-dev": "^1.0.1", - "electron-log": "^2.2.15", + "electron-log": "^2.2.17", "electron-updater": "4.0.6", "prop-types": "^15.6.2", "react": "^16.6.3", @@ -24,6 +25,7 @@ "semver": "^5.5.0", "simple-spellchecker": "^0.9.8", "underscore": "^1.9.1", + "valid-url": "^1.0.9", "winreg": "^1.2.4", "yargs": "^3.32.0" } diff --git a/src/utils/util.js b/src/utils/util.js index ad08ebef..73504585 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -3,11 +3,21 @@ // See LICENSE.txt for license information. import url from 'url'; +import {isUri, isHttpUri, isHttpsUri} from 'valid-url'; + function getDomain(inputURL) { const parsedURL = url.parse(inputURL); return `${parsedURL.protocol}//${parsedURL.host}`; } +function isValidURL(testURL) { + return Boolean(isHttpUri(testURL) || isHttpsUri(testURL)); +} + +function isValidURI(testURL) { + return Boolean(isUri(testURL)); +} + // 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 @@ -25,5 +35,7 @@ function isInternalURL(targetURL, currentURL, basename = '/') { export default { getDomain, + isValidURL, + isValidURI, isInternalURL, }; diff --git a/test/specs/app_test.js b/test/specs/app_test.js index c8cb40b1..0e9dc17d 100644 --- a/test/specs/app_test.js +++ b/test/specs/app_test.js @@ -71,9 +71,29 @@ describe('application', function desc() { }); it('should show index.html when there is config file', async () => { - fs.writeFileSync(env.configFilePath, JSON.stringify({ - url: env.mattermostURL, - })); + const config = { + version: 1, + teams: [{ + name: 'example', + url: env.mattermostURL, + }, { + name: 'github', + url: 'https://github.com/', + }], + showTrayIcon: false, + trayIconTheme: 'light', + minimizeToTray: false, + notifications: { + flashWindow: 0, + bounceIcon: false, + bounceIconType: 'informational', + }, + showUnreadBadge: true, + useSpellChecker: true, + enableHardwareAcceleration: true, + autostart: true, + }; + fs.writeFileSync(env.configFilePath, JSON.stringify(config)); await this.app.restart(); const url = await this.app.client.getUrl(); @@ -82,18 +102,19 @@ describe('application', function desc() { it('should upgrade v0 config file', async () => { const Config = require('../../src/common/config').default; - const config = new Config(env.configFilePath); - fs.writeFileSync(env.configFilePath, JSON.stringify({ + const newConfig = new Config(env.configFilePath); + const oldConfig = { url: env.mattermostURL, - })); + }; + fs.writeFileSync(env.configFilePath, JSON.stringify(oldConfig)); await this.app.restart(); const url = await this.app.client.getUrl(); url.should.match(/\/index.html$/); const str = fs.readFileSync(env.configFilePath, 'utf8'); - const localConfigData = JSON.parse(str); - localConfigData.version.should.equal(config.defaultData.version); + const upgradedConfig = JSON.parse(str); + upgradedConfig.version.should.equal(newConfig.defaultData.version); }); it.skip('should be stopped when the app instance already exists', (done) => { diff --git a/test/specs/browser/index_test.js b/test/specs/browser/index_test.js index f9c6847c..b2e2e15c 100644 --- a/test/specs/browser/index_test.js +++ b/test/specs/browser/index_test.js @@ -22,6 +22,18 @@ describe('browser/index.html', function desc() { name: 'github', url: 'https://github.com/', }], + showTrayIcon: false, + trayIconTheme: 'light', + minimizeToTray: false, + notifications: { + flashWindow: 0, + bounceIcon: false, + bounceIconType: 'informational', + }, + showUnreadBadge: true, + useSpellChecker: true, + enableHardwareAcceleration: true, + autostart: true, }; const serverPort = 8181; @@ -91,7 +103,8 @@ describe('browser/index.html', function desc() { waitForVisible('#mattermostView0', 2000, true); }); - it('should show error when using incorrect URL', async () => { + // validation now prevents incorrect url's from being used + it.skip('should show error when using incorrect URL', async () => { this.timeout(30000); fs.writeFileSync(env.configFilePath, JSON.stringify({ version: 1, diff --git a/test/specs/browser/settings_test.js b/test/specs/browser/settings_test.js index 725c1fee..28ff1aa6 100644 --- a/test/specs/browser/settings_test.js +++ b/test/specs/browser/settings_test.js @@ -20,6 +20,18 @@ describe('browser/settings.html', function desc() { name: 'github', url: 'https://github.com/', }], + showTrayIcon: false, + trayIconTheme: 'light', + minimizeToTray: false, + notifications: { + flashWindow: 0, + bounceIcon: false, + bounceIconType: 'informational', + }, + showUnreadBadge: true, + useSpellChecker: true, + enableHardwareAcceleration: true, + autostart: true, }; beforeEach(async () => { diff --git a/test/specs/permisson_test.js b/test/specs/permisson_test.js index c008ac38..bf042da6 100644 --- a/test/specs/permisson_test.js +++ b/test/specs/permisson_test.js @@ -9,6 +9,15 @@ import PermissionManager from '../../src/main/PermissionManager'; const permissionFile = path.join(env.userDataDir, 'permission.json'); +const ORIGIN1 = 'https://example.com'; +const PERMISSION1 = 'notifications'; + +const ORIGIN2 = 'https://example2.com'; +const PERMISSION2 = 'test'; + +const DENIED = 'denied'; +const GRANTED = 'granted'; + describe('PermissionManager', function() { beforeEach(function(done) { fs.unlink(permissionFile, () => { @@ -17,73 +26,67 @@ describe('PermissionManager', function() { }); it('should grant a permission for an origin', function() { - const ORIGIN = 'origin'; - const PERMISSION = 'permission'; const manager = new PermissionManager(permissionFile); - manager.isGranted(ORIGIN, PERMISSION).should.be.false; - manager.isDenied(ORIGIN, PERMISSION).should.be.false; + manager.isGranted(ORIGIN1, PERMISSION1).should.be.false; + manager.isDenied(ORIGIN1, PERMISSION1).should.be.false; - manager.grant(ORIGIN, PERMISSION); + manager.grant(ORIGIN1, PERMISSION1); - manager.isGranted(ORIGIN, PERMISSION).should.be.true; - manager.isDenied(ORIGIN, PERMISSION).should.be.false; + manager.isGranted(ORIGIN1, PERMISSION1).should.be.true; + manager.isDenied(ORIGIN1, PERMISSION1).should.be.false; - manager.isGranted(ORIGIN + '_another', PERMISSION).should.be.false; - manager.isGranted(ORIGIN, PERMISSION + '_another').should.be.false; + manager.isGranted(ORIGIN2, PERMISSION1).should.be.false; + manager.isGranted(ORIGIN1, PERMISSION2).should.be.false; }); it('should deny a permission for an origin', function() { - const ORIGIN = 'origin'; - const PERMISSION = 'permission'; const manager = new PermissionManager(permissionFile); - manager.isGranted(ORIGIN, PERMISSION).should.be.false; - manager.isDenied(ORIGIN, PERMISSION).should.be.false; + manager.isGranted(ORIGIN1, PERMISSION1).should.be.false; + manager.isDenied(ORIGIN1, PERMISSION1).should.be.false; - manager.deny(ORIGIN, PERMISSION); + manager.deny(ORIGIN1, PERMISSION1); - manager.isGranted(ORIGIN, PERMISSION).should.be.false; - manager.isDenied(ORIGIN, PERMISSION).should.be.true; + manager.isGranted(ORIGIN1, PERMISSION1).should.be.false; + manager.isDenied(ORIGIN1, PERMISSION1).should.be.true; - manager.isDenied(ORIGIN + '_another', PERMISSION).should.be.false; - manager.isDenied(ORIGIN, PERMISSION + '_another').should.be.false; + manager.isDenied(ORIGIN2, PERMISSION1).should.be.false; + manager.isDenied(ORIGIN1, PERMISSION2).should.be.false; }); it('should save permissions to the file', function() { - const ORIGIN = 'origin'; - const PERMISSION = 'permission'; const manager = new PermissionManager(permissionFile); - manager.deny(ORIGIN, PERMISSION); - manager.grant(ORIGIN + '_another', PERMISSION + '_another'); + manager.deny(ORIGIN1, PERMISSION1); + manager.grant(ORIGIN2, PERMISSION2); JSON.parse(fs.readFileSync(permissionFile)).should.deep.equal({ - origin: { - permission: 'denied', + [ORIGIN1]: { + [PERMISSION1]: DENIED, }, - origin_another: { - permission_another: 'granted', + [ORIGIN2]: { + [PERMISSION2]: GRANTED, }, }); }); it('should restore permissions from the file', function() { fs.writeFileSync(permissionFile, JSON.stringify({ - origin: { - permission: 'denied', + [ORIGIN1]: { + [PERMISSION1]: DENIED, }, - origin_another: { - permission_another: 'granted', + [ORIGIN2]: { + [PERMISSION2]: GRANTED, }, })); const manager = new PermissionManager(permissionFile); - manager.isDenied('origin', 'permission').should.be.true; - manager.isGranted('origin_another', 'permission_another').should.be.true; + manager.isDenied(ORIGIN1, PERMISSION1).should.be.true; + manager.isGranted(ORIGIN2, PERMISSION2).should.be.true; }); it('should allow permissions for trusted URLs', function() { fs.writeFileSync(permissionFile, JSON.stringify({})); - const manager = new PermissionManager(permissionFile, ['https://example.com', 'https://example2.com/2']); - manager.isGranted('https://example.com', 'notifications').should.be.true; - manager.isGranted('https://example2.com', 'test').should.be.true; + const manager = new PermissionManager(permissionFile, [ORIGIN1, ORIGIN2]); + manager.isGranted(ORIGIN1, PERMISSION1).should.be.true; + manager.isGranted(ORIGIN2, PERMISSION2).should.be.true; }); }); diff --git a/test/specs/security_test.js b/test/specs/security_test.js index c1273aac..9e53c671 100644 --- a/test/specs/security_test.js +++ b/test/specs/security_test.js @@ -9,7 +9,7 @@ const http = require('http'); const env = require('../modules/environment'); -describe.skip('application', function desc() { +describe.skip('security', function desc() { this.timeout(30000); const serverPort = 8181; diff --git a/test/specs/utils/util_test.js b/test/specs/utils/util_test.js index 6c266443..8e46fc00 100644 --- a/test/specs/utils/util_test.js +++ b/test/specs/utils/util_test.js @@ -8,6 +8,54 @@ import assert from 'assert'; import Utils from '../../../src/utils/util'; describe('Utils', () => { + describe('isValidURL', () => { + it('should be true for a valid web url', () => { + const testURL = 'https://developers.mattermost.com/'; + assert.equal(Utils.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); + }); + it('should be true for an invalid, self-defined, top-level domain', () => { + const testURL = 'https://www.example.x'; + assert.equal(Utils.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); + }); + it('should be true for a permalink url', () => { + const testURL = 'https://community.mattermost.com/test-channel/pl/pdqowkij47rmbyk78m5hwc7r6r'; + assert.equal(Utils.isValidURL(testURL), true); + }); + it('should be true for a valid, internal domain', () => { + const testURL = 'https://mattermost.company-internal'; + assert.equal(Utils.isValidURL(testURL), true); + }); + it('should be true for a second, valid internal domain', () => { + const testURL = 'https://serverXY/mattermost'; + assert.equal(Utils.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); + }); + 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); + }); + }); + 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); + }); + it('should be false for a malicious url', () => { + const testURL = String.raw`mattermost:///" --data-dir "\\deans-mbp\mattermost`; + assert.equal(Utils.isValidURI(testURL), false); + }); + }); describe('isInternalURL', () => { it('should be false for different hosts', () => { const currentURL = url.parse('http://localhost/team/channel1');