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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ import {configPath, updatePaths} from 'main/constants';
import CriticalErrorHandler from 'main/CriticalErrorHandler'; import CriticalErrorHandler from 'main/CriticalErrorHandler';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import i18nManager from 'main/i18nManager'; import i18nManager from 'main/i18nManager';
import NonceManager from 'main/nonceManager';
import {getDoNotDisturb} from 'main/notifications'; import {getDoNotDisturb} from 'main/notifications';
import parseArgs from 'main/ParseArgs'; import parseArgs from 'main/ParseArgs';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
@ -313,6 +314,20 @@ async function initializeAfterAppReady() {
app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID app.setAppUserModelId('Mattermost.Desktop'); // Use explicit AppUserModelID
const defaultSession = session.defaultSession; 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') { if (process.platform !== 'darwin') {
defaultSession.on('spellcheck-dictionary-download-failure', (event, lang) => { 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, OPEN_WINDOWS_MICROPHONE_PREFERENCES,
GET_MEDIA_ACCESS_STATUS, GET_MEDIA_ACCESS_STATUS,
VIEW_FINISHED_RESIZING, VIEW_FINISHED_RESIZING,
GET_NONCE,
} from 'common/communication'; } from 'common/communication';
console.log('Preload initialized'); console.log('Preload initialized');
@ -124,6 +125,7 @@ contextBridge.exposeInMainWorld('desktop', {
goBack: () => ipcRenderer.send(HISTORY, -1), goBack: () => ipcRenderer.send(HISTORY, -1),
checkForUpdates: () => ipcRenderer.send(CHECK_FOR_UPDATES), checkForUpdates: () => ipcRenderer.send(CHECK_FOR_UPDATES),
updateConfiguration: (saveQueueItems) => ipcRenderer.send(UPDATE_CONFIGURATION, saveQueueItems), updateConfiguration: (saveQueueItems) => ipcRenderer.send(UPDATE_CONFIGURATION, saveQueueItems),
getNonce: () => ipcRenderer.invoke(GET_NONCE),
updateServerOrder: (serverOrder) => ipcRenderer.send(UPDATE_SERVER_ORDER, serverOrder), updateServerOrder: (serverOrder) => ipcRenderer.send(UPDATE_SERVER_ORDER, serverOrder),
updateTabOrder: (serverId, viewOrder) => ipcRenderer.send(UPDATE_TAB_ORDER, serverId, viewOrder), 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.loadURL('mattermost-desktop://renderer/downloadsDropdown.html');
this.view.webContents.session.webRequest.onHeadersReceived(downloadsManager.webRequestOnHeadersReceivedHandler);
MainWindow.get()?.addBrowserView(this.view); MainWindow.get()?.addBrowserView(this.view);
}; };

View file

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

View file

@ -32,6 +32,10 @@ type Props = {
intl: IntlShape; intl: IntlShape;
}; };
type State = {
nonce?: string;
}
function getStyle(style?: DraggingStyle | NotDraggingStyle) { function getStyle(style?: DraggingStyle | NotDraggingStyle) {
if (style?.transform) { if (style?.transform) {
const axisLockX = `${style.transform.slice(0, style.transform.indexOf(','))}, 0px)`; const axisLockX = `${style.transform.slice(0, style.transform.indexOf(','))}, 0px)`;
@ -43,7 +47,12 @@ function getStyle(style?: DraggingStyle | NotDraggingStyle) {
return style; 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) => { onCloseTab = (id: string) => {
return (event: React.MouseEvent<HTMLButtonElement>) => { return (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation(); event.stopPropagation();
@ -51,7 +60,19 @@ class TabBar extends React.PureComponent<Props> {
}; };
}; };
componentDidMount(): void {
window.desktop.getNonce().then((nonce) => {
this.setState({
nonce,
});
});
}
render() { render() {
if (!this.state.nonce) {
return null;
}
const tabs = this.props.tabs.map((tab, index) => { const tabs = this.props.tabs.map((tab, index) => {
const sessionExpired = this.props.sessionsExpired[tab.id!]; const sessionExpired = this.props.sessionsExpired[tab.id!];
const hasUnreads = this.props.unreadCounts[tab.id!]; const hasUnreads = this.props.unreadCounts[tab.id!];
@ -145,7 +166,10 @@ class TabBar extends React.PureComponent<Props> {
}); });
return ( return (
<DragDropContext onDragEnd={this.props.onDrop}> <DragDropContext
nonce={this.state.nonce}
onDragEnd={this.props.onDrop}
>
<Droppable <Droppable
isDropDisabled={this.props.tabsDisabled} isDropDisabled={this.props.tabsDisabled}
droppableId='tabBar' 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; hasGPOServers?: boolean;
isAnyDragging: boolean; isAnyDragging: boolean;
windowBounds?: Electron.Rectangle; windowBounds?: Electron.Rectangle;
nonce?: string;
} }
function getStyle(style?: DraggingStyle | NotDraggingStyle) { function getStyle(style?: DraggingStyle | NotDraggingStyle) {
@ -141,6 +142,9 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
window.desktop.serverDropdown.requestInfo(); window.desktop.serverDropdown.requestInfo();
window.addEventListener('click', this.closeMenu); window.addEventListener('click', this.closeMenu);
window.addEventListener('keydown', this.handleKeyboardShortcuts); window.addEventListener('keydown', this.handleKeyboardShortcuts);
window.desktop.getNonce().then((nonce) => {
this.setState({nonce});
});
} }
componentDidUpdate() { componentDidUpdate() {
@ -231,6 +235,10 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
}; };
render() { render() {
if (!this.state.nonce) {
return null;
}
return ( return (
<IntlProvider> <IntlProvider>
<div <div
@ -256,6 +264,7 @@ class ServerDropdown extends React.PureComponent<Record<string, never>, State> {
</div> </div>
<hr className='ServerDropdown__divider'/> <hr className='ServerDropdown__divider'/>
<DragDropContext <DragDropContext
nonce={this.state.nonce}
onDragStart={this.onDragStart} onDragStart={this.onDragStart}
onDragEnd={this.onDragEnd} onDragEnd={this.onDragEnd}
> >

View file

@ -2,7 +2,6 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <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> <title><%= htmlWebpackPlugin.options.title %></title>
</head> </head>
<body> <body>

View file

@ -1,14 +1,14 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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() { export default function addDarkModeListener() {
const setDarkMode = (darkMode: boolean) => { const setDarkMode = (darkMode: boolean) => {
if (darkMode) { if (darkMode) {
darkStyles.use(); document.body.classList.add('darkMode');
} else { } else {
darkStyles.unuse(); document.body.classList.remove('darkMode');
} }
}; };
window.desktop.onDarkModeChange(setDarkMode); window.desktop.onDarkModeChange(setDarkMode);

View file

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

View file

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