From 36c6106cade4cdb939ee3aa78e5268fc9c5fd3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Thu, 28 May 2020 10:53:57 +0200 Subject: [PATCH] [MM-22648] basic auth external sites (#1295) * fix not using babel * wip * added tests, moved to map, polifill-like to convert between object and map * basic structure setup * working, found new bug * change buttons * fix login issue * remove logging code * address CR comments * remove custom function in favor of airbnb shim * fix linting * fix PM requested changes * [MM-25323] fix basic auth cancelling * fix crash when multiple request were made * address UX comments, added external link for user convenience --- package-lock.json | 185 ++++++++++++++++-- package.json | 4 +- src/browser/components/LoginModal.jsx | 31 ++- src/browser/components/MainPage.jsx | 30 +-- src/browser/components/PermissionModal.jsx | 151 ++++++++++++++ src/browser/components/externalLink.jsx | 32 +++ .../components/PermissionRequestDialog.css | 9 +- src/common/permissions.js | 16 ++ src/main.js | 60 ++++-- src/main/Validator.js | 21 ++ src/main/trustedOrigins.js | 102 ++++++++++ src/utils/util.js | 9 + test/specs/main/trusted_origins_test.js | 99 ++++++++++ test/specs/utils/util_test.js | 12 ++ webpack.config.main.js | 11 ++ 15 files changed, 721 insertions(+), 51 deletions(-) create mode 100644 src/browser/components/PermissionModal.jsx create mode 100644 src/browser/components/externalLink.jsx create mode 100644 src/common/permissions.js create mode 100644 src/main/trustedOrigins.js create mode 100644 test/specs/main/trusted_origins_test.js diff --git a/package-lock.json b/package-lock.json index 0076e954..d7a02651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,16 +95,161 @@ } }, "@babel/helper-create-class-features-plugin": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.2.3.tgz", - "integrity": "sha512-xO/3Gn+2C7/eOUeb0VRnSP1+yvWHNxlpAot1eMhtoKDCN7POsyQP5excuT5UsV5daHxMWBeIIOeI5cmB8vMRgQ==", + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.9.6.tgz", + "integrity": "sha512-6N9IeuyHvMBRyjNYOMJHrhwtu4WJMrYf8hVbEHD3pbbbmNOk1kmXSQs7bA4dYDUaIx4ZEzdnvo6NwC3WHd/Qow==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.1.0", - "@babel/helper-member-expression-to-functions": "^7.0.0", - "@babel/helper-optimise-call-expression": "^7.0.0", - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/helper-replace-supers": "^7.2.3" + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3", + "@babel/helper-replace-supers": "^7.9.6", + "@babel/helper-split-export-declaration": "^7.8.3" + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz", + "integrity": "sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==", + "dev": true, + "requires": { + "@babel/highlight": "^7.8.3" + } + }, + "@babel/generator": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.9.6.tgz", + "integrity": "sha512-+htwWKJbH2bL72HRluF8zumBxzuX0ZZUFl3JLNyoUjM/Ho8wnVpPXM6aUz8cfKDqQ/h7zHqKt4xzJteUosckqQ==", + "dev": true, + "requires": { + "@babel/types": "^7.9.6", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz", + "integrity": "sha512-JVcQZeXM59Cd1qanDUxv9fgJpt3NeKUaqBqUEvfmQ+BCOKq2xUgaWZW2hr0dkbyJgezYuplEoh5knmrnS68efw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.8.3", + "@babel/template": "^7.8.3", + "@babel/types": "^7.9.5" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz", + "integrity": "sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz", + "integrity": "sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz", + "integrity": "sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz", + "integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==", + "dev": true + }, + "@babel/helper-replace-supers": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.9.6.tgz", + "integrity": "sha512-qX+chbxkbArLyCImk3bWV+jB5gTNU/rsze+JlcF6Nf8tVTigPJSI1o1oBow/9Resa1yehUO9lIipsmu9oG4RzA==", + "dev": true, + "requires": { + "@babel/helper-member-expression-to-functions": "^7.8.3", + "@babel/helper-optimise-call-expression": "^7.8.3", + "@babel/traverse": "^7.9.6", + "@babel/types": "^7.9.6" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz", + "integrity": "sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==", + "dev": true, + "requires": { + "@babel/types": "^7.8.3" + } + }, + "@babel/highlight": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.0", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.9.6.tgz", + "integrity": "sha512-AoeIEJn8vt+d/6+PXDRPaksYhnlbMIiejioBZvvMQsOjW/JYK6k/0dKnvvP3EhK5GfMBWDPtrxRtegWdAcdq9Q==", + "dev": true + }, + "@babel/template": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz", + "integrity": "sha512-zbMsPMy/v0PWFZEhQJ66bqjhH+z0JgMoBWuikXybgG3Gkd/3t5oQ1Rw2WQhnSrsOmsKXnZOx15tkC4qON/+JPg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/parser": "^7.8.6", + "@babel/types": "^7.8.6" + } + }, + "@babel/traverse": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.9.6.tgz", + "integrity": "sha512-b3rAHSjbxy6VEAvlxM8OV/0X4XrG72zoxme6q1MOoe2vd0bEc+TwayhuC1+Dfgqh1QEG+pj7atQqvUprHIccsg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@babel/generator": "^7.9.6", + "@babel/helper-function-name": "^7.9.5", + "@babel/helper-split-export-declaration": "^7.8.3", + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.13" + } + }, + "@babel/types": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.9.6.tgz", + "integrity": "sha512-qxXzvBO//jO9ZnoasKF1uJzHd2+M6Q2ZPIVfnFps8JJvXy0ZBbwbNOmE6SGIY5XOY6d1Bo5lb9d9RJ8nv3WSeA==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.9.5", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + } } }, "@babel/helper-define-map": { @@ -257,6 +402,12 @@ "@babel/types": "^7.0.0" } }, + "@babel/helper-validator-identifier": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz", + "integrity": "sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g==", + "dev": true + }, "@babel/helper-wrap-function": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz", @@ -309,13 +460,21 @@ } }, "@babel/plugin-proposal-class-properties": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.2.0.tgz", - "integrity": "sha512-pdBj4Hvyt/L1LA0Vvm/Tkp9gNDGLIkSbX8IrYfWcoA5xUZIg8qjSOTChUSaCjYdvoMcB9y2bXVGNVZ5DH62CZg==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.8.3.tgz", + "integrity": "sha512-EqFhbo7IosdgPgZggHaNObkmO1kNUe3slaKu54d5OWvy+p9QIKOzK1GAEpAIsZtWVtPXUHSMcT4smvDrCfY4AA==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.2.0", - "@babel/helper-plugin-utils": "^7.0.0" + "@babel/helper-create-class-features-plugin": "^7.8.3", + "@babel/helper-plugin-utils": "^7.8.3" + }, + "dependencies": { + "@babel/helper-plugin-utils": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz", + "integrity": "sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==", + "dev": true + } } }, "@babel/plugin-proposal-json-strings": { diff --git a/package.json b/package.json index 069c144d..114b943d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "devDependencies": { "7zip-bin": "^4.1.0", "@babel/core": "^7.2.0", - "@babel/plugin-proposal-class-properties": "^7.2.0", + "@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-object-rest-spread": "^7.2.0", "@babel/preset-env": "^7.2.0", "@babel/preset-react": "^7.0.0", @@ -59,11 +59,11 @@ "electron-connect": "^0.6.3", "electron-notarize": "^0.1.1", "eslint": "^6.6.0", - "eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee", "eslint-plugin-cypress": "^2.7.0", "eslint-plugin-eslint-comments": "^3.1.2", "eslint-plugin-header": "^3.0.0", "eslint-plugin-import": "^2.18.2", + "eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee", "eslint-plugin-react": "^7.16.0", "file-loader": "^2.0.0", "image-webpack-loader": "5.0.0", diff --git a/src/browser/components/LoginModal.jsx b/src/browser/components/LoginModal.jsx index 0b039930..49f7596b 100644 --- a/src/browser/components/LoginModal.jsx +++ b/src/browser/components/LoginModal.jsx @@ -8,23 +8,36 @@ import {Button, Col, ControlLabel, Form, FormGroup, FormControl, Modal} from 're export default class LoginModal extends React.Component { constructor(props) { super(props); - this.username = ''; - this.password = ''; + this.state = { + username: '', + password: '', + }; } handleSubmit = (event) => { event.preventDefault(); - this.props.onLogin(this.props.request, this.username, this.password); - this.username = ''; - this.password = ''; + this.props.onLogin(this.props.request, this.state.username, this.state.password); + this.setState({ + username: '', + password: '', + }); + } + + handleCancel = (event) => { + event.preventDefault(); + this.props.onCancel(this.props.request); + this.setState({ + username: '', + password: '', + }); } setUsername = (e) => { - this.username = e.target.value; + this.setState({username: e.target.value}); } setPassword = (e) => { - this.password = e.target.value; + this.setState({password: e.target.value}); } render() { @@ -60,6 +73,7 @@ export default class LoginModal extends React.Component { type='text' placeholder='User Name' onChange={this.setUsername} + value={this.state.username} onClick={(e) => { e.stopPropagation(); }} @@ -76,6 +90,7 @@ export default class LoginModal extends React.Component { type='password' placeholder='Password' onChange={this.setPassword} + value={this.state.password} onClick={(e) => { e.stopPropagation(); }} @@ -90,7 +105,7 @@ export default class LoginModal extends React.Component { bsStyle='primary' >{'Login'} { ' ' } - + diff --git a/src/browser/components/MainPage.jsx b/src/browser/components/MainPage.jsx index 33b5cfc3..da56e669 100644 --- a/src/browser/components/MainPage.jsx +++ b/src/browser/components/MainPage.jsx @@ -29,6 +29,7 @@ import HoveringURL from './HoveringURL.jsx'; import Finder from './Finder.jsx'; import NewTeamModal from './NewTeamModal.jsx'; import SelectCertificateModal from './SelectCertificateModal.jsx'; +import PermissionModal from './PermissionModal.jsx'; import ExtraBar from './ExtraBar.jsx'; export default class MainPage extends React.Component { @@ -126,17 +127,7 @@ export default class MainPage extends React.Component { } ipcRenderer.on('login-request', (event, request, authInfo) => { - self.setState({ - loginRequired: true, - }); - const loginQueue = self.state.loginQueue; - loginQueue.push({ - request, - authInfo, - }); - self.setState({ - loginQueue, - }); + this.loginRequest(event, request, authInfo); }); ipcRenderer.on('select-user-certificate', (_, origin, certificateList) => { @@ -367,6 +358,18 @@ export default class MainPage extends React.Component { } } + loginRequest = (event, request, authInfo) => { + const loginQueue = this.state.loginQueue; + loginQueue.push({ + request, + authInfo, + }); + this.setState({ + loginRequired: true, + loginQueue, + }); + }; + componentDidUpdate(prevProps, prevState) { if (prevState.key !== this.state.key) { // i.e. When tab has been changed this.refs[`mattermostView${this.state.key}`].focusOnWebView(); @@ -502,7 +505,9 @@ export default class MainPage extends React.Component { this.setState({loginQueue}); } - handleLoginCancel = () => { + handleLoginCancel = (request) => { + ipcRenderer.send('login-cancel', request); + const loginQueue = this.state.loginQueue; loginQueue.shift(); this.setState({loginQueue}); @@ -836,6 +841,7 @@ export default class MainPage extends React.Component { onLogin={this.handleLogin} onCancel={this.handleLoginCancel} /> + { + switch (permission) { + case BASIC_AUTH_PERMISSION: + this.requestBasicAuthPermission(event, request, authInfo, permission); + break; + default: + console.warn(`Unknown permission request: ${permission}`); + ipcRenderer.send(DENY_PERMISSION_CHANNEL, request, permission); + } + }); + } + + requestBasicAuthPermission(event, request, authInfo, permission) { + const key = getKey(request, permission); + this.requestPermission(key, request.url, permission).then(() => { + ipcRenderer.send(GRANT_PERMISSION_CHANNEL, request.url, permission); + ipcRenderer.sendTo(remote.getCurrentWindow().webContents.id, 'login-request', request, authInfo); + this.loadNext(); + }).catch((err) => { + ipcRenderer.send(DENY_PERMISSION_CHANNEL, request.url, permission, err.message); + ipcRenderer.send('login-cancel', request); + this.loadNext(); + }); + } + + requestPermission(key, url, permission) { + return new Promise((resolve, reject) => { + const tracker = new Map(this.state.tracker); + const permissionRequest = { + grant: resolve, + deny: () => reject(new Error(`User denied ${permission} to ${url}`)), + url, + permission, + }; + tracker.set(key, permissionRequest); + const current = this.state.current ? this.state.current : key; + this.setState({ + tracker, + current, + }); + }); + } + + getCurrentData() { + if (this.state.current) { + return this.state.tracker.get(this.state.current); + } + return { + grant: () => { + const err = new Error(); + log.error(`There isn't any permission to grant access to.\n Stack trace:\n${err.stack}`); + }, + deny: () => { + const err = new Error(); + log.error(`There isn't any permission to deny access to.\n Stack trace:\n${err.stack}`); + } + }; + } + + loadNext() { + const tracker = new Map(this.state.tracker); + tracker.delete(this.state.current); + const nextKey = tracker.keys().next(); + const current = nextKey.done ? null : nextKey.value; + this.setState({ + tracker, + current, + }); + } + + getModalTitle() { + const {permission} = this.getCurrentData(); + return `${PERMISSION_DESCRIPTION[permission]} Required`; + } + + getModalBody() { + const {url, permission} = this.getCurrentData(); + const originDisplay = url ? Util.getHost(url) : 'unknown origin'; + const originLink = url ? originDisplay : ''; + return ( +
+

+ {`A site that's not included in your Mattermost server configuration requires access for ${PERMISSION_DESCRIPTION[permission]}.`} +

+

+ {'This request originated from '} + {`${originDisplay}`} +

+
+ ); + } + + render() { + const {grant, deny} = this.getCurrentData(); + return ( + + + {this.getModalTitle()} + + + {this.getModalBody()} + + +
+ + +
+
+
+ ); + } +} +/* eslint-enable react/no-set-state */ diff --git a/src/browser/components/externalLink.jsx b/src/browser/components/externalLink.jsx new file mode 100644 index 00000000..56fe7a57 --- /dev/null +++ b/src/browser/components/externalLink.jsx @@ -0,0 +1,32 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {ipcRenderer} from 'electron'; + +// 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) { + const click = (e) => { + e.preventDefault(); + let parseUrl; + try { + parseUrl = new URL(props.href); + ipcRenderer.send('confirm-protocol', parseUrl.protocol, props.href); + } catch (err) { + console.error(`invalid url ${props.href} supplied to externallink: ${err}`); + } + }; + const options = { + onClick: click, + ...props, + }; + return ( + + ); +} + +ExternalLink.propTypes = { + href: PropTypes.string.isRequired, +}; \ No newline at end of file diff --git a/src/browser/css/components/PermissionRequestDialog.css b/src/browser/css/components/PermissionRequestDialog.css index af124327..1b7537a6 100644 --- a/src/browser/css/components/PermissionRequestDialog.css +++ b/src/browser/css/components/PermissionRequestDialog.css @@ -21,7 +21,7 @@ .PermissionRequestDialog-content .PermissionRequestDialog-content-buttons { margin-bottom: 0; text-align: right; -} +} .PermissionRequestDialog-content .PermissionRequestDialog-content-buttons > * { margin-right: 7px; @@ -42,3 +42,10 @@ .PermissionRequestDialog-content .PermissionRequestDialog-content-close:hover { color: black; } + +.permission-modal .modal-dialog { + max-width: 580px; +} +.permission-modal .remove-border { + border: none; +} \ No newline at end of file diff --git a/src/common/permissions.js b/src/common/permissions.js new file mode 100644 index 00000000..86b629ff --- /dev/null +++ b/src/common/permissions.js @@ -0,0 +1,16 @@ + +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// channel types for managing permissions +export const REQUEST_PERMISSION_CHANNEL = 'request-permission'; +export const GRANT_PERMISSION_CHANNEL = 'grant-permission'; +export const DENY_PERMISSION_CHANNEL = 'deny-permission'; + +// Permission types that can be requested +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 5eb2b7f9..623b82c5 100644 --- a/src/main.js +++ b/src/main.js @@ -10,6 +10,7 @@ import electron, {nativeTheme} from 'electron'; import isDev from 'electron-is-dev'; import installExtension, {REACT_DEVELOPER_TOOLS} from 'electron-devtools-installer'; import log from 'electron-log'; +import 'airbnb-js-shims/target/es2015'; import {protocols} from '../electron-builder.json'; @@ -20,6 +21,7 @@ import upgradeAutoLaunch from './main/autoLaunch'; import RegistryConfig from './common/config/RegistryConfig'; import Config from './common/config'; import CertificateStore from './main/certificateStore'; +import TrustedOriginsStore from './main/trustedOrigins'; import createMainWindow from './main/mainWindow'; import appMenu from './main/menus/app'; import trayMenu from './main/menus/tray'; @@ -32,6 +34,7 @@ import SpellChecker from './main/SpellChecker'; import UserActivityMonitor from './main/UserActivityMonitor'; import Utils from './utils/util'; import parseArgs from './main/ParseArgs'; +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 @@ -59,6 +62,7 @@ let mainWindow = null; let popupWindow = null; let hideOnStartup = null; let certificateStore = null; +let trustedOriginsStore = null; let spellChecker = null; let deeplinkingUrl = null; let scheme = null; @@ -180,11 +184,13 @@ function initializeAppEventListeners() { function initializeBeforeAppReady() { certificateStore = CertificateStore.load(path.resolve(app.getPath('userData'), 'certificate.json')); + trustedOriginsStore = new TrustedOriginsStore(path.resolve(app.getPath('userData'), 'trustedOrigins.json')); + trustedOriginsStore.load(); // prevent using a different working directory, which happens on windows running after installation. const expectedPath = path.dirname(process.execPath); if (process.cwd() !== expectedPath && !isDev) { - console.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`); + log.warn(`Current working directory is ${process.cwd()}, changing into ${expectedPath}`); process.chdir(expectedPath); } @@ -219,6 +225,7 @@ function initializeBeforeAppReady() { function initializeInterCommunicationEventListeners() { ipcMain.on('reload-config', handleReloadConfig); ipcMain.on('login-credentials', handleLoginCredentialsEvent); + ipcMain.on('login-cancel', handleCancelLoginEvent); ipcMain.on('download-url', handleDownloadURLEvent); ipcMain.on('notified', handleNotifiedEvent); ipcMain.on('update-title', handleUpdateTitleEvent); @@ -229,6 +236,8 @@ function initializeInterCommunicationEventListeners() { ipcMain.on('get-spellchecker-locale', handleGetSpellcheckerLocaleEvent); ipcMain.on('reply-on-spellchecker-is-ready', handleReplyOnSpellcheckerIsReadyEvent); ipcMain.on('selected-client-certificate', handleSelectedCertificate); + ipcMain.on(GRANT_PERMISSION_CHANNEL, handlePermissionGranted); + ipcMain.on(DENY_PERMISSION_CHANNEL, handlePermissionDenied); if (shouldShowTrayIcon()) { ipcMain.on('update-unread', handleUpdateUnreadEvent); @@ -334,16 +343,16 @@ function handleSelectCertificate(event, webContents, url, list, callback) { function handleSelectedCertificate(event, server, cert) { const callback = certificateRequests.get(server); if (!callback) { - console.error(`there was no callback associated with: ${server}`); + log.error(`there was no callback associated with: ${server}`); return; } if (typeof cert === 'undefined') { - console.log('user canceled certificate selection'); + log.info('user canceled certificate selection'); } else { try { callback(cert); } catch (e) { - console.log(`There was a problem using the selected certificate: ${e}`); + log.error(`There was a problem using the selected certificate: ${e}`); } } } @@ -363,7 +372,7 @@ function handleAppCertificateError(event, webContents, url, error, certificate, // if we are already showing that error, don't add more dialogs if (certificateErrorCallbacks.has(errorID)) { - console.log(`Ignoring already shown dialog for ${errorID}`); + log.warn(`Ignoring already shown dialog for ${errorID}`); certificateErrorCallbacks.set(errorID, callback); return; } @@ -417,11 +426,24 @@ function handleAppGPUProcessCrashed(event, killed) { function handleAppLogin(event, webContents, request, authInfo, callback) { event.preventDefault(); - if (!isTrustedURL(request.url)) { - return; + const parsedURL = new URL(request.url); + const server = Utils.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)) { + mainWindow.webContents.send('login-request', request, authInfo); + } else { + mainWindow.webContents.send(REQUEST_PERMISSION_CHANNEL, request, authInfo, BASIC_AUTH_PERMISSION); } - loginCallbackMap.set(JSON.stringify(request), callback); - mainWindow.webContents.send('login-request', request, authInfo); +} + +function handlePermissionGranted(event, url, permission) { + trustedOriginsStore.addPermission(url, permission); + trustedOriginsStore.save(); +} + +function handlePermissionDenied(event, url, permission, reason) { + log.warn(`Permission request denied: ${reason}`); } function handleAppWillFinishLaunching() { @@ -808,10 +830,20 @@ function initializeAfterAppReady() { // function handleLoginCredentialsEvent(event, request, user, password) { - const callback = loginCallbackMap.get(JSON.stringify(request)); + const callback = loginCallbackMap.get(request.url); + if (typeof callback === 'undefined') { + log.error(`Failed to retrieve login callback for ${request.url}`); + return; + } if (callback != null) { callback(user, password); } + loginCallbackMap.delete(request.url); +} + +function handleCancelLoginEvent(event, request) { + log.info(`Cancelling request for ${request ? request.url : 'unknown'}`); + handleLoginCredentialsEvent(event, request); // we use undefined to cancel the request } function handleDownloadURLEvent(event, url) { @@ -821,7 +853,7 @@ function handleDownloadURLEvent(event, url) { type: 'error', message: err.toString(), }); - console.log(err); + log.error(err); } }); } @@ -924,11 +956,11 @@ function handleUpdateDictionaryEvent(_, localeSelected) { path.resolve(app.getAppPath(), 'node_modules/simple-spellchecker/dict'), (err) => { if (err) { - console.error(err); + log.error(err); } }); } catch (e) { - console.error('couldn\'t load a spellchecker for locale'); + log.error('couldn\'t load a spellchecker for locale'); } } } @@ -989,7 +1021,6 @@ function handleMainWindowWebContentsCrashed() { function isTrustedURL(url) { const parsedURL = Utils.parseURL(url); if (!parsedURL) { - console.log('not an url'); return false; } return Utils.getServer(parsedURL, config.teams) !== null; @@ -1121,7 +1152,6 @@ function wasUpdated(lastAppVersion) { function clearAppCache() { if (mainWindow) { - console.log('Clear cache after update'); mainWindow.webContents.session.clearCache().then(mainWindow.reload); } else { //Wait for mainWindow diff --git a/src/main/Validator.js b/src/main/Validator.js index d7e21cf4..81d17118 100644 --- a/src/main/Validator.js +++ b/src/main/Validator.js @@ -91,6 +91,17 @@ const certificateStoreSchema = Joi.object().pattern( }) ); +const originPermissionsSchema = Joi.object().keys({ + canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want +}); + +const trustedOriginsSchema = Joi.object({}).pattern( + Joi.string().uri(), + Joi.object().keys({ + canBasicAuth: Joi.boolean().default(false), // we can add more permissions later if we want + }), +); + const allowedProtocolsSchema = Joi.array().items(Joi.string().regex(/^[a-z-]+:$/i)); // validate bounds_info.json @@ -165,6 +176,16 @@ export function validateAllowedProtocols(data) { return validateAgainstSchema(data, allowedProtocolsSchema); } +export function validateTrustedOriginsStore(data) { + const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); + return validateAgainstSchema(jsonData, trustedOriginsSchema); +} + +export function validateOriginPermissions(data) { + const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); + return validateAgainstSchema(jsonData, originPermissionsSchema); +} + function validateAgainstSchema(data, schema) { if (typeof data !== 'object') { console.error(`Input 'data' is not an object we can validate: ${typeof data}`); diff --git a/src/main/trustedOrigins.js b/src/main/trustedOrigins.js new file mode 100644 index 00000000..64bbcbb5 --- /dev/null +++ b/src/main/trustedOrigins.js @@ -0,0 +1,102 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +'use strict'; + +import fs from 'fs'; + +import log from 'electron-log'; + +import Utils from '../utils/util.js'; + +import * as Validator from './Validator'; + +export default class TrustedOriginsStore { + constructor(storeFile) { + this.storeFile = storeFile; + } + + // don't use this, is for ease of mocking it on testing + readFromFile = () => { + let storeData; + try { + storeData = fs.readFileSync(this.storeFile, 'utf-8'); + } catch (e) { + storeData = null; + } + return storeData; + } + + load = () => { + const storeData = this.readFromFile(); + let result = {}; + if (storeData !== null) { + result = Validator.validateTrustedOriginsStore(storeData); + if (!result) { + throw new Error('Provided TrustedOrigins file does not validate, using defaults instead.'); + } + } + this.data = new Map(Object.entries(result)); + } + + // don't use this, is for ease of mocking it on testing + saveToFile(stringMap) { + fs.writeFileSync(this.storeFile, stringMap); + } + + save = () => { + this.saveToFile(JSON.stringify(Object.fromEntries((this.data.entries())), null, ' ')); + }; + + // if permissions or targetUrl are invalid, this function will throw an error + // this function stablishes all the permissions at once, overwriting whatever was before + // to enable just one permission use addPermission instead. + set = (targetURL, permissions) => { + const validPermissions = Validator.validateOriginPermissions(permissions); + if (!validPermissions) { + throw new Error(`Invalid permissions set for trusting ${targetURL}`); + } + this.data.set(Utils.getHost(targetURL), validPermissions); + }; + + // enables usage of `targetURL` for `permission` + addPermission = (targetURL, permission) => { + const origin = Utils.getHost(targetURL); + const currentPermissions = this.data.get(origin) || {}; + currentPermissions[permission] = true; + this.set(origin, currentPermissions); + } + + delete = (targetURL) => { + let host; + try { + host = Utils.getHost(targetURL); + this.data.delete(host); + } catch { + return false; + } + return true; + } + + isExisting = (targetURL) => { + return (typeof this.data.get(Utils.getHost(targetURL)) !== 'undefined'); + }; + + // if user hasn't set his preferences, it will return null (falsy) + checkPermission = (targetURL, permission) => { + if (!permission) { + log.error(`Missing permission request on ${targetURL}`); + return null; + } + let origin; + try { + origin = Utils.getHost(targetURL); + } catch (e) { + log.error(`invalid host to retrieve permissions: ${targetURL}: ${e}`); + return null; + } + + const urlPermissions = this.data.get(origin); + return urlPermissions ? urlPermissions[permission] : null; + } +} diff --git a/src/utils/util.js b/src/utils/util.js index df4ced57..9fefc24a 100644 --- a/src/utils/util.js +++ b/src/utils/util.js @@ -34,6 +34,14 @@ function parseURL(inputURL) { } } +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 @@ -183,4 +191,5 @@ export default { isPluginUrl, getDisplayBoundaries, dispatchNotification, + getHost, }; diff --git a/test/specs/main/trusted_origins_test.js b/test/specs/main/trusted_origins_test.js new file mode 100644 index 00000000..5d4c579c --- /dev/null +++ b/test/specs/main/trusted_origins_test.js @@ -0,0 +1,99 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +import assert from 'assert'; +import 'airbnb-js-shims/target/es2015'; + +import TrustedOriginsStore from '../../../src/main/trustedOrigins.js'; +import {BASIC_AUTH_PERMISSION} from '../../../src/common/permissions.js'; + +function mockTOS(fileName, returnvalue) { + const tos = new TrustedOriginsStore(fileName); + tos.readFromFile = () => { + return returnvalue; + }; + return tos; +} + +describe('Trusted Origins', () => { + describe('validate load', () => { + it('should be empty if there is no file', () => { + const tos = mockTOS('emptyfile', null); + tos.load(); + assert.deepEqual(tos.data.size, 0); + }); + + it('should throw an error if data isn\'t an object', () => { + const tos = mockTOS('notobject', 'this is not my object!'); + + assert.throws(tos.load, SyntaxError); + }); + + it('should throw an error if data isn\'t in the expected format', () => { + const tos = mockTOS('badobject', '{"https://mattermost.com": "this is not my object!"}'); + assert.throws(tos.load, /^Error: Provided TrustedOrigins file does not validate, using defaults instead\.$/); + }); + + it('should drop keys that aren\'t urls', () => { + const tos = mockTOS('badobject2', `{"this is not an uri": {"${BASIC_AUTH_PERMISSION}": true}}`); + tos.load(); + assert.equal(typeof tos.data['this is not an uri'], 'undefined'); + }); + + it('should contain valid data if everything goes right', () => { + const value = { + 'https://mattermost.com': { + [BASIC_AUTH_PERMISSION]: true, + }}; + const tos = mockTOS('okfile', JSON.stringify(value)); + tos.load(); + assert.deepEqual(Object.fromEntries(tos.data.entries()), value); + }); + }); + describe('validate testing permissions', () => { + const value = { + 'https://mattermost.com': { + [BASIC_AUTH_PERMISSION]: true, + }, + 'https://notmattermost.com': { + [BASIC_AUTH_PERMISSION]: false, + }, + }; + const tos = mockTOS('permission_test', JSON.stringify(value)); + tos.load(); + it('tos should contain 2 elements', () => { + assert.equal(tos.data.size, 2); + }); + it('should say ok if the permission is set', () => { + assert.equal(tos.checkPermission('https://mattermost.com', BASIC_AUTH_PERMISSION), true); + }); + it('should say ko if the permission is set to false', () => { + assert.equal(tos.checkPermission('https://notmattermost.com', BASIC_AUTH_PERMISSION), false); + }); + it('should say ko if the uri is not set', () => { + assert.equal(tos.checkPermission('https://undefined.com', BASIC_AUTH_PERMISSION), null); + }); + it('should say null if the permission is unknown', () => { + assert.equal(tos.checkPermission('https://mattermost.com'), null); + }); + }); + + describe('validate deleting permissions', () => { + const value = { + 'https://mattermost.com': { + [BASIC_AUTH_PERMISSION]: true, + }, + 'https://notmattermost.com': { + [BASIC_AUTH_PERMISSION]: false, + }, + }; + const tos = mockTOS('permission_test', JSON.stringify(value)); + tos.load(); + it('deleting revokes access', () => { + assert.equal(tos.checkPermission('https://mattermost.com', BASIC_AUTH_PERMISSION), true); + tos.delete('https://mattermost.com'); + assert.equal(tos.checkPermission('https://mattermost.com', BASIC_AUTH_PERMISSION), null); + }); + }); +}); \ No newline at end of file diff --git a/test/specs/utils/util_test.js b/test/specs/utils/util_test.js index 8e46fc00..ca4d8113 100644 --- a/test/specs/utils/util_test.js +++ b/test/specs/utils/util_test.js @@ -92,4 +92,16 @@ describe('Utils', () => { assert.equal(Utils.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'); + }); + + it('shoud raise an error on malformed urls', () => { + const myurl = 'http://example.com:-80/'; + assert.throws(() => Utils.getHost(myurl), Error); + }); + }); }); diff --git a/webpack.config.main.js b/webpack.config.main.js index 8c6b72a5..ba32bd46 100644 --- a/webpack.config.main.js +++ b/webpack.config.main.js @@ -18,6 +18,17 @@ module.exports = merge(base, { path: path.join(__dirname, 'src'), filename: '[name]_bundle.js', }, + module: { + rules: [{ + test: /\.js?$/, + use: { + loader: 'babel-loader', + options: { + include: ['@babel/plugin-proposal-class-properties'] + } + }, + }], + }, node: { __filename: true, __dirname: true,