[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
This commit is contained in:
Guillermo Vayá 2020-05-28 10:53:57 +02:00 committed by GitHub
parent 53f1f40774
commit 36c6106cad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 721 additions and 51 deletions

185
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

@ -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'}</Button>
{ ' ' }
<Button onClick={this.props.onCancel}>{'Cancel'}</Button>
<Button onClick={this.handleCancel}>{'Cancel'}</Button>
</div>
</Col>
</FormGroup>

View file

@ -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}
/>
<PermissionModal/>
<SelectCertificateModal
certificateRequests={this.state.certificateRequests}
onSelect={this.handleSelectCertificate}

View file

@ -0,0 +1,151 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable react/no-set-state */
import React from 'react';
import {Modal, Button} from 'react-bootstrap';
import {ipcRenderer, remote} from 'electron';
import {log} from 'electron-log';
import {BASIC_AUTH_PERMISSION, REQUEST_PERMISSION_CHANNEL, DENY_PERMISSION_CHANNEL, GRANT_PERMISSION_CHANNEL, PERMISSION_DESCRIPTION} from '../../common/permissions';
import Util from '../../utils/util';
import ExternalLink from './externalLink.jsx';
function getKey(request, permission) {
return `${request.url}:${permission}`;
}
export default class PermissionModal extends React.Component {
constructor(props) {
super(props);
this.state = {
tracker: new Map(), // permission request order is not preserved, but we won't have repetition of requests.
current: null,
};
ipcRenderer.on(REQUEST_PERMISSION_CHANNEL, (event, request, authInfo, permission) => {
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 (
<div>
<p>
{`A site that's not included in your Mattermost server configuration requires access for ${PERMISSION_DESCRIPTION[permission]}.`}
</p>
<p>
<span>{'This request originated from '}</span>
<ExternalLink href={originLink}>{`${originDisplay}`}</ExternalLink>
</p>
</div>
);
}
render() {
const {grant, deny} = this.getCurrentData();
return (
<Modal
bsClass='modal'
className='permission-modal'
show={Boolean(this.state.current)}
id='requestPermissionModal'
enforceFocus={true}
>
<Modal.Header>
<Modal.Title>{this.getModalTitle()}</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.getModalBody()}
</Modal.Body>
<Modal.Footer className={'remove-border'}>
<div>
<Button
onClick={deny}
>{'Cancel'}</Button>
<Button
bsStyle='primary'
onClick={grant}
>{'Accept'}</Button>
</div>
</Modal.Footer>
</Modal>
);
}
}
/* eslint-enable react/no-set-state */

View file

@ -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 (
<a {...options}/>
);
}
ExternalLink.propTypes = {
href: PropTypes.string.isRequired,
};

View file

@ -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;
}

16
src/common/permissions.js Normal file
View file

@ -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',
};

View file

@ -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

View file

@ -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}`);

102
src/main/trustedOrigins.js Normal file
View file

@ -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;
}
}

View file

@ -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,
};

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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,