From c9f671d82ad8d5d25d5261b9cdc293f0e12bd189 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Mon, 12 Aug 2024 09:38:59 -0400 Subject: [PATCH] [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 --- package-lock.json | 3 +- package.json | 1 + src/common/communication.ts | 2 + src/main/app/initialize.test.js | 3 ++ src/main/app/initialize.ts | 15 +++++++ src/main/nonceManager.ts | 34 +++++++++++++++ src/main/preload/internalAPI.js | 2 + src/main/views/downloadsDropdownView.ts | 1 - src/renderer/components/SettingsPage.tsx | 49 +++++++++++++++------- src/renderer/components/TabBar.tsx | 28 ++++++++++++- src/renderer/css/lazy/modals-dark.lazy.css | 36 ---------------- src/renderer/css/modals-dark.scss | 38 +++++++++++++++++ src/renderer/dropdown.tsx | 9 ++++ src/renderer/index.html | 1 - src/renderer/modals/darkMode.ts | 6 +-- src/types/window.ts | 1 + webpack.config.renderer.js | 9 +++- 17 files changed, 177 insertions(+), 61 deletions(-) create mode 100644 src/main/nonceManager.ts delete mode 100644 src/renderer/css/lazy/modals-dark.lazy.css create mode 100644 src/renderer/css/modals-dark.scss diff --git a/package-lock.json b/package-lock.json index c3a53a22..494691c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 5171d01e..085e736f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/communication.ts b/src/common/communication.ts index d1cc0a8b..49b2bddd 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -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'; diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index d5dc8fe0..eb0ba7ef 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -64,6 +64,9 @@ jest.mock('electron', () => ({ }, session: { defaultSession: { + webRequest: { + onHeadersReceived: jest.fn(), + }, setSpellCheckerDictionaryDownloadURL: jest.fn(), setPermissionRequestHandler: jest.fn(), on: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index 637bbb48..a972b926 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -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) => { diff --git a/src/main/nonceManager.ts b/src/main/nonceManager.ts new file mode 100644 index 00000000..a68f4ef9 --- /dev/null +++ b/src/main/nonceManager.ts @@ -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; + + 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; diff --git a/src/main/preload/internalAPI.js b/src/main/preload/internalAPI.js index 161f3b59..6762f8ee 100644 --- a/src/main/preload/internalAPI.js +++ b/src/main/preload/internalAPI.js @@ -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), diff --git a/src/main/views/downloadsDropdownView.ts b/src/main/views/downloadsDropdownView.ts index 1b771514..09a01ad2 100644 --- a/src/main/views/downloadsDropdownView.ts +++ b/src/main/views/downloadsDropdownView.ts @@ -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); }; diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index ff0daa9c..505db991 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -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 & { 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 { 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 { 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 { {this.state.useSpellChecker && - - } - /> + + + } + /> + } , ); diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx index 82973a0c..b3dde114 100644 --- a/src/renderer/components/TabBar.tsx +++ b/src/renderer/components/TabBar.tsx @@ -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 { +class TabBar extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = {}; + } + onCloseTab = (id: string) => { return (event: React.MouseEvent) => { event.stopPropagation(); @@ -51,7 +60,19 @@ class TabBar extends React.PureComponent { }; }; + 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 { }); return ( - + , 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, State> { }; render() { + if (!this.state.nonce) { + return null; + } + return (
, State> {

diff --git a/src/renderer/index.html b/src/renderer/index.html index af9a017b..dcd28249 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,6 @@ - <%= htmlWebpackPlugin.options.title %> diff --git a/src/renderer/modals/darkMode.ts b/src/renderer/modals/darkMode.ts index d28e1d73..e270e6b2 100644 --- a/src/renderer/modals/darkMode.ts +++ b/src/renderer/modals/darkMode.ts @@ -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); diff --git a/src/types/window.ts b/src/types/window.ts index 583f2fa8..c5c39182 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -44,6 +44,7 @@ declare global { goBack: () => void; checkForUpdates: () => void; updateConfiguration: (saveQueueItems: SaveQueueItem[]) => void; + getNonce: () => Promise; updateServerOrder: (serverOrder: string[]) => Promise; updateTabOrder: (serverId: string, viewOrder: string[]) => Promise; diff --git a/webpack.config.renderer.js b/webpack.config.renderer.js index 1181243a..ac118402 100644 --- a/webpack.config.renderer.js +++ b/webpack.config.renderer.js @@ -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$/,