Skip to content

Commit

Permalink
Implement a security manager
Browse files Browse the repository at this point in the history
See: #633

Custom extensions from extensions.turbowarp.org will be loaded
automatically and without sandbox.

For other extensions, a prompt has been added to ask the user for
permission to load the extension.
  • Loading branch information
GarboMuffin committed Oct 30, 2022
1 parent 86e4b52 commit 2cebe4e
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 5 deletions.
15 changes: 11 additions & 4 deletions src/components/gui/gui.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import ConnectionModal from '../../containers/connection-modal.jsx';
import TelemetryModal from '../telemetry-modal/telemetry-modal.jsx';
import TWUsernameModal from '../../containers/tw-username-modal.jsx';
import TWSettingsModal from '../../containers/tw-settings-modal.jsx';
import TWSecurityManager from '../../containers/tw-security-manager.jsx';

import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants';
import {resolveStageSize} from '../../lib/screen-utils';
Expand Down Expand Up @@ -161,6 +162,14 @@ const GUIComponent = props => {
return (<MediaQuery minWidth={minWidth}>{isFullSize => {
const stageSize = resolveStageSize(stageSizeMode, isFullSize);

const alwaysEnabledModals = (
<React.Fragment>
<TWSecurityManager />
{usernameModalVisible && <TWUsernameModal />}
{settingsModalVisible && <TWSettingsModal />}
</React.Fragment>
);

return isPlayerOnly ? (
<React.Fragment>
{/* TW: When the window is fullscreen, use an element to display the background color */}
Expand All @@ -187,17 +196,15 @@ const GUIComponent = props => {
<Alerts className={styles.alertsContainer} />
) : null}
</StageWrapper>
{usernameModalVisible && <TWUsernameModal />}
{settingsModalVisible && <TWSettingsModal />}
{alwaysEnabledModals}
</React.Fragment>
) : (
<Box
className={styles.pageWrapper}
dir={isRtl ? 'rtl' : 'ltr'}
{...componentProps}
>
{usernameModalVisible && <TWUsernameModal />}
{settingsModalVisible && <TWSettingsModal />}
{alwaysEnabledModals}
{telemetryModalVisible ? (
<TelemetryModal
isRtl={isRtl}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@import "../../css/colors.css";

.modal-content {
width: 400px;
}

.body {
background: $ui-white;
padding: 1.5rem 2.25rem;
}
[theme="dark"] .body {
color: $text-primary;
background: $ui-primary;
}

.body p {
margin: 4px 0;
}

.extension {
font-family: monospace;
user-select: text;
}

.buttons {
display: flex;
justify-content: flex-end;
}
.deny-button, .allow-button {
padding: 0.75rem 1rem;
border-radius: 0.25rem;
background: white;
border: 1px solid $ui-black-transparent;
font-weight: 600;
font-size: 0.85rem;
color: black;
margin: 0 0 0 4px;
}
.deny-button {
background-color: rgb(255, 92, 92);
}
.allow-button {
background-color: #24cd11;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {defineMessages, FormattedMessage, intlShape, injectIntl} from 'react-intl';
import PropTypes from 'prop-types';
import React from 'react';
import Box from '../box/box.jsx';
import Modal from '../../containers/modal.jsx';

import styles from './security-manager-modal.css';

const messages = defineMessages({
title: {
defaultMessage: 'Custom Extensions',
description: 'Title of modal shown when asking for permission to automatically load custom extension',
id: 'tw.securityManager.title'
}
});

const SecurityManagerModalComponent = props => (
<Modal
className={styles.modalContent}
onRequestClose={props.onDenied}
contentLabel={props.intl.formatMessage(messages.title)}
id="securitymanagermodal"
>
<Box className={styles.body}>
<p>
<FormattedMessage
defaultMessage="The project wants to load the custom extension:"
description="Part of modal shown when asking for permission to automatically load custom extension"
id="tw.securityManager.label"
/>
</p>
<p className={styles.extension}>
{props.extensionURL}
</p>
<p>
<FormattedMessage
// eslint-disable-next-line max-len
defaultMessage="If you allow this, the extension's code will be downloaded and run on your computer."
description="Part of modal shown when asking for permission to automatically load custom extension"
id="tw.securityManager.download"
/>
</p>
<p>
<FormattedMessage
// eslint-disable-next-line max-len
defaultMessage="While the code will be sandboxed, we can't guarantee this will be 100% safe. Make sure you trust this extension before continuing."
description="Part of modal shown when asking for permission to automatically load custom extension"
id="tw.securityManager.sandbox"
/>

</p>
<Box className={styles.buttons}>
<button
className={styles.denyButton}
onClick={props.onDenied}
>
<FormattedMessage
defaultMessage="Deny"
description="Refuse modal asking for permission to automatically load custom extension"
id="tw.securityManager.deny"
/>
</button>
<button
className={styles.allowButton}
onClick={props.onAllowed}
>
<FormattedMessage
defaultMessage="Allow"
description="Refuse modal asking for permission to automatically load custom extension"
id="tw.securityManager.allow"
/>
</button>
</Box>
</Box>
</Modal>
);

SecurityManagerModalComponent.propTypes = {
intl: intlShape,
extensionURL: PropTypes.string.isRequired,
onAllowed: PropTypes.func.isRequired,
onDenied: PropTypes.func.isRequired
};

export default injectIntl(SecurityManagerModalComponent);
113 changes: 113 additions & 0 deletions src/containers/tw-security-manager.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import log from '../lib/log';
import bindAll from 'lodash.bindall';
import SecurityManagerModal from '../components/tw-security-manager-modal/security-manager-modal.jsx';

const SAFE_EXTENSION_SITES = [
// Extensions that start with these URLs will be loaded automatically and without a sandbox.
// Be careful adding entries to this list.
// Each entry MUST have a trailing / after the domain for this to provide any security.
'https://extensions.turbowarp.org/'
];

class TWSecurityManagerComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'getSandboxMode',
'canLoadExtensionFromProject',
'handleAllowed',
'handleDenied'
]);
this.state = {
modalVisible: false,
modalURL: '',
modalCallback: null
};
}

componentDidMount () {
const securityManager = this.props.vm.extensionManager.securityManager;
securityManager.getSandboxMode = this.getSandboxMode;
securityManager.canLoadExtensionFromProject = this.canLoadExtensionFromProject;
}

/**
* @param {string} url The extension's URL
* @returns {string} The VM worker mode to use
*/
getSandboxMode (url) {
if (SAFE_EXTENSION_SITES.some(site => url.startsWith(site))) {
log.info(`Loading extension ${url} unsandboxed`);
return 'unsandboxed';
}
return 'iframe';
}

/**
* @param {string} url The extension's URL
* @returns {boolean} Whether the extension can be loaded
*/
async canLoadExtensionFromProject (url) {
if (SAFE_EXTENSION_SITES.some(site => url.startsWith(site))) {
log.info(`Loading extension ${url} automatically`);
return true;
}
const isAllowed = await new Promise(resolve => {
this.setState({
modalVisible: true,
modalURL: url,
modalCallback: resolve
});
});
this.setState({
modalVisible: false
});
return isAllowed;
}

handleAllowed () {
this.state.modalCallback(true);
}

handleDenied () {
this.state.modalCallback(false);
}

render () {
if (this.state.modalVisible) {
return (
<SecurityManagerModal
extensionURL={this.state.modalURL}
onAllowed={this.handleAllowed}
onDenied={this.handleDenied}
/>
);
}
return null;
}
}

TWSecurityManagerComponent.propTypes = {
vm: PropTypes.shape({
extensionManager: PropTypes.shape({
securityManager: PropTypes.shape({
getSandboxMode: PropTypes.func.isRequired,
canLoadExtensionFromProject: PropTypes.func.isRequired
}).isRequired
}).isRequired
}).isRequired
};

const mapStateToProps = state => ({
vm: state.scratchGui.vm
});

const mapDispatchToProps = () => ({});

export default connect(
mapStateToProps,
mapDispatchToProps
)(TWSecurityManagerComponent);
1 change: 0 additions & 1 deletion src/reducers/vm.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {MAXIMUM_CLOUD_VARIABLES} from '../lib/tw-cloud-limits';
const SET_VM = 'scratch-gui/vm/SET_VM';
const defaultVM = new VM();
defaultVM.setCompatibilityMode(true);
defaultVM.extensionManager.workerMode = 'iframe';
defaultVM.runtime.cloudOptions.limit = MAXIMUM_CLOUD_VARIABLES;
defaultVM.attachStorage(storage);
const initialState = defaultVM;
Expand Down

0 comments on commit 2cebe4e

Please sign in to comment.