[MM-59552] Remove unsafe-inline from internal CSP, replace with nonce and rework some dynamic styling (#3120)

* Remove unsafe-inline

* Fix dynamic dark mode loading

* Include nonce generator for CSP for styles

* Add nonce provider for react-select

* Fix test
This commit is contained in:
Devin Binnie 2024-08-12 09:38:59 -04:00 committed by GitHub
parent e310fa705f
commit c9f671d82a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 177 additions and 61 deletions

3
package-lock.json generated
View file

@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@emotion/react": "11.11.4",
"@mattermost/compass-icons": "0.1.45",
"auto-launch": "5.0.6",
"bootstrap": "4.6.1",
@ -88,7 +89,7 @@
},
"api-types": {
"name": "@mattermost/desktop-api",
"version": "5.9.0-1",
"version": "5.10.0-1",
"dev": true,
"license": "MIT",
"peerDependencies": {

View file

@ -159,6 +159,7 @@
"webpack-merge": "5.8.0"
},
"dependencies": {
"@emotion/react": "11.11.4",
"@mattermost/compass-icons": "0.1.45",
"auto-launch": "5.0.6",
"bootstrap": "4.6.1",

View file

@ -191,3 +191,5 @@ export const GET_MEDIA_ACCESS_STATUS = 'get-media-access-status';
// Legacy code remove signal
export const LEGACY_OFF = 'legacy-off';
export const GET_NONCE = 'get-nonce';

View file

@ -64,6 +64,9 @@ jest.mock('electron', () => ({
},
session: {
defaultSession: {
webRequest: {
onHeadersReceived: jest.fn(),
},
setSpellCheckerDictionaryDownloadURL: jest.fn(),
setPermissionRequestHandler: jest.fn(),
on: jest.fn(),

View file

@ -45,6 +45,7 @@ import {configPath, updatePaths} from 'main/constants';
import CriticalErrorHandler from 'main/CriticalErrorHandler';
import downloadsManager from 'main/downloadsManager';
import i18nManager from 'main/i18nManager';
import NonceManager from 'main/nonceManager';
import {getDoNotDisturb} from 'main/notifications';
import parseArgs from 'main/ParseArgs';
import PermissionsManager from 'main/permissionsManager';
@ -313,6 +314,20 @@ async function initializeAfterAppReady() {
app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID
const defaultSession = session.defaultSession;
defaultSession.webRequest.onHeadersReceived((details, callback) => {
const url = parseURL(details.url);
if (url?.protocol === 'mattermost-desktop:' && url?.pathname.endsWith('html')) {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [`default-src 'self'; style-src 'self' 'nonce-${NonceManager.create(details.url)}'; media-src data:; img-src 'self' data:`],
},
});
return;
}
downloadsManager.webRequestOnHeadersReceivedHandler(details, callback);
});
if (process.platform !== 'darwin') {
defaultSession.on('spellcheck-dictionary-download-failure', (event, lang) => {

34
src/main/nonceManager.ts Normal file
View file

@ -0,0 +1,34 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ipcMain} from 'electron';
import type {IpcMainInvokeEvent} from 'electron';
import {v4 as uuid} from 'uuid';
import {GET_NONCE} from 'common/communication';
export class NonceManager {
private nonces: Map<string, string>;
constructor() {
this.nonces = new Map();
ipcMain.handle(GET_NONCE, this.handleGetNonce);
}
create = (url: string) => {
const nonce = uuid();
this.nonces.set(url, nonce);
return nonce;
};
private handleGetNonce = (event: IpcMainInvokeEvent) => {
const url = event.sender.getURL();
const nonce = this.nonces.get(url);
this.nonces.delete(url);
return nonce;
};
}
const nonceManager = new NonceManager();
export default nonceManager;

View file

@ -91,6 +91,7 @@ import {
OPEN_WINDOWS_MICROPHONE_PREFERENCES,
GET_MEDIA_ACCESS_STATUS,
VIEW_FINISHED_RESIZING,
GET_NONCE,
} from 'common/communication';
console.log('Preload initialized');
@ -124,6 +125,7 @@ contextBridge.exposeInMainWorld('desktop', {
goBack: () => ipcRenderer.send(HISTORY, -1),
checkForUpdates: () => ipcRenderer.send(CHECK_FOR_UPDATES),
updateConfiguration: (saveQueueItems) => ipcRenderer.send(UPDATE_CONFIGURATION, saveQueueItems),
getNonce: () => ipcRenderer.invoke(GET_NONCE),
updateServerOrder: (serverOrder) => ipcRenderer.send(UPDATE_SERVER_ORDER, serverOrder),
updateTabOrder: (serverId, viewOrder) => ipcRenderer.send(UPDATE_TAB_ORDER, serverId, viewOrder),

View file

@ -66,7 +66,6 @@ export class DownloadsDropdownView {
}});
this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdown.html');
this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler);
MainWindow.get()?.addBrowserView(this.view);
};

View file

@ -6,6 +6,9 @@
import 'renderer/css/settings.css';
import createCache from '@emotion/cache';
import {CacheProvider} from '@emotion/react';
import type {EmotionCache} from '@emotion/react';
import React from 'react';
import {FormCheck, Col, FormGroup, FormText, Container, Row, Button, FormControl, Modal} from 'react-bootstrap';
import type {IntlShape} from 'react-intl';
@ -39,6 +42,7 @@ type State = DeepPartial<CombinedConfig> & {
availableLanguages: Array<{label: string; value: string}>;
availableSpellcheckerLanguages: Array<{label: string; value: string}>;
canUpgrade?: boolean;
cache?: EmotionCache;
}
type SavingStateItems = {
@ -127,6 +131,13 @@ class SettingsPage extends React.PureComponent<Props, State> {
availableLanguages.sort((a, b) => a.label.localeCompare(b.label));
this.setState({availableLanguages});
});
window.desktop.getNonce().then((nonce) => {
this.setState({cache: createCache({
key: 'react-select-cache',
nonce,
})});
});
}
getConfig = () => {
@ -426,6 +437,10 @@ class SettingsPage extends React.PureComponent<Props, State> {
render() {
const {intl} = this.props;
if (!this.state.cache) {
return null;
}
const settingsPage = {
close: {
textDecoration: 'none',
@ -581,22 +596,24 @@ class SettingsPage extends React.PureComponent<Props, State> {
</FormText>
</FormCheck>
{this.state.useSpellChecker &&
<ReactSelect
inputId='inputSpellCheckerLocalesDropdown'
className='SettingsPage__spellCheckerLocalesDropdown'
classNamePrefix='SettingsPage__spellCheckerLocalesDropdown'
options={this.state.availableSpellcheckerLanguages}
isMulti={true}
isClearable={false}
onChange={this.handleChangeSpellCheckerLocales}
value={this.selectedSpellCheckerLocales}
placeholder={
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.preferredLanguages'
defaultMessage='Select preferred language(s)'
/>
}
/>
<CacheProvider value={this.state.cache}>
<ReactSelect
inputId='inputSpellCheckerLocalesDropdown'
className='SettingsPage__spellCheckerLocalesDropdown'
classNamePrefix='SettingsPage__spellCheckerLocalesDropdown'
options={this.state.availableSpellcheckerLanguages}
isMulti={true}
isClearable={false}
onChange={this.handleChangeSpellCheckerLocales}
value={this.selectedSpellCheckerLocales}
placeholder={
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.preferredLanguages'
defaultMessage='Select preferred language(s)'
/>
}
/>
</CacheProvider>
}
</>,
);

View file

@ -32,6 +32,10 @@ type Props = {
intl: IntlShape;
};
type State = {
nonce?: string;
}
function getStyle(style?: DraggingStyle | NotDraggingStyle) {
if (style?.transform) {
const axisLockX = `${style.transform.slice(0, style.transform.indexOf(','))}, 0px)`;
@ -43,7 +47,12 @@ function getStyle(style?: DraggingStyle | NotDraggingStyle) {
return style;
}
class TabBar extends React.PureComponent<Props> {
class TabBar extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
onCloseTab = (id: string) => {
return (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
@ -51,7 +60,19 @@ class TabBar extends React.PureComponent<Props> {
};
};
componentDidMount(): void {
window.desktop.getNonce().then((nonce) => {
this.setState({
nonce,
});
});
}
render() {
if (!this.state.nonce) {
return null;
}
const tabs = this.props.tabs.map((tab, index) => {
const sessionExpired = this.props.sessionsExpired[tab.id!];
const hasUnreads = this.props.unreadCounts[tab.id!];
@ -145,7 +166,10 @@ class TabBar extends React.PureComponent<Props> {
});
return (
<DragDropContext onDragEnd={this.props.onDrop}>
<DragDropContext
nonce={this.state.nonce}
onDragEnd={this.props.onDrop}
>
<Droppable
isDropDisabled={this.props.tabsDisabled}
droppableId='tabBar'

View file

@ -1,36 +0,0 @@
@import '~bootstrap-dark/src/bootstrap-dark.css';
body {
background-color: transparent;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__control {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__menu {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__option {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__option:hover {
background: rgba(255, 255, 255, 0.16);
}
#settingsModal .modal-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--light) rgba(255, 255, 255, 0);
}
.Toggle .Toggle___switch {
background: rgba(var(--center-channel-bg-rgb), 0.24);
}
.Toggle .Toggle___switch.disabled {
background: rgba(var(--center-channel-bg-rgb), 0.08);
}

View file

@ -0,0 +1,38 @@
@use 'sass:meta';
.darkMode {
@include meta.load-css('bootstrap-dark/src/bootstrap-dark.css');
color: #fff;
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__control {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__menu {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__option {
background: #242a30;
}
.SettingsPage__spellCheckerLocalesDropdown .SettingsPage__spellCheckerLocalesDropdown__option:hover {
background: rgba(255, 255, 255, 0.16);
}
#settingsModal .modal-body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--light) rgba(255, 255, 255, 0);
}
.Toggle___switch {
background: rgba(var(--center-channel-bg-rgb), 0.24);
}
.Toggle___switch.disabled {
background: rgba(var(--center-channel-bg-rgb), 0.08);
}
}

View file

@ -29,6 +29,7 @@ type State = {
hasGPOServers?: boolean;
isAnyDragging: boolean;
windowBounds?: Electron.Rectangle;
nonce?: string;
}
function getStyle(style?: DraggingStyle | NotDraggingStyle) {
@ -141,6 +142,9 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
window.desktop.serverDropdown.requestInfo();
window.addEventListener('click', this.closeMenu);
window.addEventListener('keydown', this.handleKeyboardShortcuts);
window.desktop.getNonce().then((nonce) => {
this.setState({nonce});
});
}
componentDidUpdate() {
@ -231,6 +235,10 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
};
render() {
if (!this.state.nonce) {
return null;
}
return (
<IntlProvider>
<div
@ -256,6 +264,7 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
</div>
<hr className='ServerDropdown__divider'/>
<DragDropContext
nonce={this.state.nonce}
onDragStart={this.onDragStart}
onDragEnd={this.onDragEnd}
>

View file

@ -2,7 +2,6 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; media-src data:; img-src 'self' data:">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>

View file

@ -1,14 +1,14 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import darkStyles from 'renderer/css/lazy/modals-dark.lazy.css';
import 'renderer/css/modals-dark.scss';
export default function addDarkModeListener() {
const setDarkMode = (darkMode: boolean) => {
if (darkMode) {
darkStyles.use();
document.body.classList.add('darkMode');
} else {
darkStyles.unuse();
document.body.classList.remove('darkMode');
}
};
window.desktop.onDarkModeChange(setDarkMode);

View file

@ -44,6 +44,7 @@ declare global {
goBack: () => void;
checkForUpdates: () => void;
updateConfiguration: (saveQueueItems: SaveQueueItem[]) => void;
getNonce: () => Promise<string | undefined>;
updateServerOrder: (serverOrder: string[]) => Promise<void>;
updateTabOrder: (serverId: string, viewOrder: string[]) => Promise<void>;

View file

@ -152,7 +152,14 @@ module.exports = merge(base, {
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
{
loader: 'sass-loader',
options: {
sassOptions: {
includePaths: [path.resolve(__dirname, 'node_modules')],
},
},
},
],
}, {
test: /\.mp3$/,