[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:
@@ -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>
|
||||
|
@@ -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}
|
||||
|
151
src/browser/components/PermissionModal.jsx
Normal file
151
src/browser/components/PermissionModal.jsx
Normal 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 */
|
32
src/browser/components/externalLink.jsx
Normal file
32
src/browser/components/externalLink.jsx
Normal 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,
|
||||
};
|
@@ -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
16
src/common/permissions.js
Normal 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',
|
||||
};
|
60
src/main.js
60
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
|
||||
|
@@ -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
102
src/main/trustedOrigins.js
Normal 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;
|
||||
}
|
||||
}
|
@@ -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,
|
||||
};
|
||||
|
Reference in New Issue
Block a user