diff --git a/docs/mdm-managed-configuration.md b/docs/mdm-managed-configuration.md new file mode 100644 index 00000000000..33cd15dd827 --- /dev/null +++ b/docs/mdm-managed-configuration.md @@ -0,0 +1,111 @@ +# Wire Desktop: Managed Configuration (MDM) + +This document describes how to configure Wire Desktop using Mobile Device Management (MDM) or Group Policy so that enterprises can enforce settings without user override. + +## Overview + +- **Platforms:** Windows and macOS. Managed configuration is read at application startup and cached for the lifetime of the process. +- **Precedence:** Managed values override user settings and file-based configuration (`init.json`). When a setting is managed, users cannot change it in the app. +- **Apply scope:** Managed configuration applies in all builds (production and internal). To target a specific backend (e.g. staging), use the appropriate Wire build and/or **webappUrl**; the environment selector is not configurable via MDM. + +## Supported managed settings + +| Key | Type | Description | Validation | +| --- | --- | --- | --- | +| **webappUrl** | string | Custom web app URL (e.g. on-premise). When set, the app loads this URL instead of the default backend. | `http` or `https` URI | +| **updateUrlWin** | string | Windows: URL used to check for application updates. | `http` or `https` URI | +| **proxyServerUrl** | string | Proxy server URL (e.g. `http://proxy.example.com:8080` or `https://user:password@proxy.example.com:8080`). | `http`, `https`, `socks4`, or `socks5` URI | +| **disableAutoUpdate** | boolean | If `true`, disables automatic application updates (Windows Squirrel and macOS). | boolean | +| **downloadPath** | string | Folder for user-content downloads (files from Wire), relative to the user’s home directory. Supported on Windows and macOS. | Non-empty string; must not escape home (e.g. no `..` traversal) | +| **enableSpellChecking** | boolean | Enable or disable the in-app spell checker. | boolean | +| **showMenuBar** | boolean | Show or hide the application menu bar. | boolean | +| **autoLaunch** | boolean | Start Wire when the user logs in to the OS. | boolean | +| **locale** | string | UI language. Use a two-letter code (e.g. `en`, `de`) or language-region (e.g. `pt-BR`, `en-US`). The app maps these to its supported languages. | Pattern: `[a-zA-Z]{2,3}(-[a-zA-Z]{2,3})?` | + +## Windows: Group Policy / Registry + +Wire Desktop reads managed configuration from the Windows Registry. You can deploy these keys via Group Policy (GPO) or any MDM that writes registry values. + +### Registry locations + +The app reads from **two** locations (policy overrides user): + +1. **Policy (recommended):** + `HKCU\Software\Policies\Wire\` +2. **User:** + `HKCU\Software\Wire\` + +`` is the application name (e.g. `Wire` for production, `WireInternal` for internal builds). It is derived from the app and must contain only letters and numbers; other characters are stripped. + +### Value types + +- **String settings** (e.g. `webappUrl`, `proxyServerUrl`, `downloadPath`, `locale`): use `REG_SZ`. +- **Boolean settings** (e.g. `disableAutoUpdate`, `enableSpellChecking`, `showMenuBar`, `autoLaunch`): use either + - `REG_DWORD` with `0` (false) or `1` (true), or + - `REG_SZ` with `true`/`false`, `yes`/`no`, or `1`/`0`. + +### Example: Group Policy (ADMX) + +If you use ADMX/ADML templates, define policies under `User Configuration` → `Administrative Templates` → a custom category for Wire, and map each policy to the registry path above. Create one policy per setting and write the value to the corresponding value name (e.g. `webappUrl`, `proxyServerUrl`). + +### Example: Registry script / MDM payload + +```text +; Policy key (HKCU\Software\Policies\Wire\Wire) +[HKEY_CURRENT_USER\Software\Policies\Wire\Wire] +"webappUrl"="https://your-wire-backend.example.com" +"proxyServerUrl"="https://proxy.corp.example.com:8080" +"disableAutoUpdate"=dword:00000001 +"downloadPath"="Documents\\WireDownloads" +"locale"="en" +"showMenuBar"=dword:00000001 +``` + +Users must restart Wire Desktop for new or changed managed settings to take effect. + +## macOS: Managed preferences + +Wire Desktop reads managed configuration from the application’s preference domain (CFPreferences / `NSUserDefaults`). You can deploy these via: + +- **MDM configuration profile** that configures managed app preferences for the Wire app, or +- **`defaults write`** (or equivalent) in the app’s preference domain, deployed by your MDM or deployment tooling. + +### Preference keys + +Use the **exact** key names from the table above (e.g. `webappUrl`, `proxyServerUrl`, `downloadPath`, `disableAutoUpdate`, `locale`, `showMenuBar`, `autoLaunch`, `enableSpellChecking`, `updateUrlWin`). + +- **String keys:** pass a string value. +- **Boolean keys:** pass a boolean (`true`/`false`) or a string that your MDM maps to a boolean. + +The app reads these at launch; the preference domain is determined by the application (bundle) identity. Confirm the exact domain for your Wire Desktop build (e.g. from the app’s Info.plist or your MDM’s app catalog). + +### Example: defaults (for testing or scripted deployment) + +```bash +# Replace the domain with your Wire app’s bundle ID or preference domain if different +defaults write com.wearezeta.zclient.mac webappUrl "https://your-wire-backend.example.com" +defaults write com.wearezeta.zclient.mac proxyServerUrl "https://proxy.corp.example.com:8080" +defaults write com.wearezeta.zclient.mac disableAutoUpdate -bool true +defaults write com.wearezeta.zclient.mac downloadPath "Documents/WireDownloads" +defaults write com.wearezeta.zclient.mac locale "en" +``` + +For production deployment, use an MDM configuration profile that sets the same keys in the app’s managed preference domain so that they cannot be changed by the user. + +Users must restart Wire Desktop for new or changed managed settings to take effect. + +## Behaviour and security notes + +- **Proxy credentials:** If you set `proxyServerUrl` with a username or password (e.g. `https://user:password@proxy:8080`), the app uses it for network requests but **never** exposes the credentials to the in-app UI or renderer process. Only the redacted URL (without user/password) is available inside the app. +- **Download path:** On Windows and macOS, `downloadPath` is interpreted relative to the user’s home directory. The app rejects paths that escape the home directory (e.g. `..` traversal). Use forward slashes or backslashes as appropriate for the platform. +- **Environment / backend:** The in-app environment selector (e.g. Production, Staging) is **not** configurable via MDM. To point users at a specific backend, set **webappUrl** to the full web app URL for that backend and use the matching Wire Desktop build if required. +- **Updates:** `updateUrlWin` applies only on Windows. `disableAutoUpdate` applies on both Windows and macOS. + +## Integrating with your MDM solution + +1. **Identify the app name / preference domain** for your Wire build (e.g. `Wire` vs `WireInternal`) so you target the correct registry path or preference domain. +2. **Deploy the chosen keys** using your MDM’s mechanism for registry (Windows) or managed preferences (macOS). +3. **Prefer policy / managed preference scope** so that settings are enforced and not overridable by the user. +4. **Communicate to users** that they need to restart Wire Desktop after you change managed configuration. + +For implementation and validation details (e.g. schema, parsing, and tests), see the code in `electron/src/settings/ManagedConfig.ts` and the planning document `docs/mdm-review-and-improvements-plan.md`. diff --git a/electron/renderer/src/components/WebView/Webview.tsx b/electron/renderer/src/components/WebView/Webview.tsx index 14bb0eab74f..eeb2b7cb9d8 100644 --- a/electron/renderer/src/components/WebView/Webview.tsx +++ b/electron/renderer/src/components/WebView/Webview.tsx @@ -169,11 +169,12 @@ const Webview = ({ setWebviewError(error); } }; - webviewRef.current?.addEventListener(ON_WEBVIEW_ERROR, listener); + const webview = webviewRef.current; + webview?.addEventListener(ON_WEBVIEW_ERROR, listener); return () => { - if (webviewRef.current) { - webviewRef.current.removeEventListener(ON_WEBVIEW_ERROR, listener); + if (webview) { + webview.removeEventListener(ON_WEBVIEW_ERROR, listener); } }; }, [webviewRef, account]); @@ -272,11 +273,12 @@ const Webview = ({ } }; - webviewRef.current?.addEventListener(ON_IPC_MESSAGE, onIpcMessage); + const webview = webviewRef.current; + webview?.addEventListener(ON_IPC_MESSAGE, onIpcMessage); return () => { - if (webviewRef.current) { - webviewRef.current.removeEventListener(ON_IPC_MESSAGE, onIpcMessage); + if (webview) { + webview.removeEventListener(ON_IPC_MESSAGE, onIpcMessage); } }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/electron/src/lib/eventType.ts b/electron/src/lib/eventType.ts index cf5edebb1ca..d80cbfd473b 100644 --- a/electron/src/lib/eventType.ts +++ b/electron/src/lib/eventType.ts @@ -39,6 +39,7 @@ export const EVENT_TYPE = { DEEP_LINK_SUBMIT: 'EVENT_TYPE.ACTION.DEEP_LINK_SUBMIT', ENCRYPT: 'EVENT_TYPE.ACTION.ENCRYPT', GET_DESKTOP_SOURCES: 'EVENT_TYPE.ACTION.GET_DESKTOP_SOURCES', + GET_MANAGED_CONFIG: 'EVENT_TYPE.ACTION.GET_MANAGED_CONFIG', GET_OG_DATA: 'EVENT_TYPE.ACTION.GET_OG_DATA', JOIN_CONVERSATION: 'EVENT_TYPE.ACTION.JOIN_CONVERSATION', NOTIFICATION_CLICK: 'EVENT_TYPE.ACTION.NOTIFICATION_CLICK', diff --git a/electron/src/locale/index.ts b/electron/src/locale/index.ts index 08e278bb104..ec7018715e6 100644 --- a/electron/src/locale/index.ts +++ b/electron/src/locale/index.ts @@ -46,6 +46,7 @@ import zh from './zh-CN.json'; import {config} from '../settings/config'; import {settings} from '../settings/ConfigurationPersistence'; +import {getManagedSettingOverride, isSettingManaged} from '../settings/ManagedConfig'; import {SettingsType} from '../settings/SettingsType'; export type i18nLanguageIdentifier = keyof typeof en; @@ -60,6 +61,25 @@ const parseLocale = (locale: string): SupportedI18nLanguage => { return languageKeys.find(languageKey => languageKey === locale) || languageKeys[0]; }; +/** + * Normalize managed locale (e.g. pt-BR, en-US) to a supported key (pt, en) so it matches + * existing app behaviour. Empty/whitespace falls back to default via parseLocale(''). + * @param {string} raw - Managed locale string from MDM (e.g. "pt-BR", "en"). + * @returns {SupportedI18nLanguage} A supported language key for the app. + */ +const normalizeManagedLocale = (raw: string): SupportedI18nLanguage => { + const trimmed = raw?.trim(); + if (!trimmed) { + return parseLocale(''); + } + const keys = Object.keys(SUPPORTED_LANGUAGES) as SupportedI18nLanguage[]; + if (keys.includes(trimmed as SupportedI18nLanguage)) { + return trimmed as SupportedI18nLanguage; + } + const primary = trimmed.split('-')[0]?.trim() || trimmed; + return parseLocale(primary); +}; + const getSystemLocale = (): SupportedI18nLanguage => parseLocale(app.getLocale().substring(0, 2)); export const LANGUAGES: SupportedI18nLanguageObject = { @@ -147,17 +167,30 @@ let current: SupportedI18nLanguage | undefined; export const getCurrent = (): SupportedI18nLanguage => { const systemLocale = getSystemLocale(); + const managedLocaleRaw = getManagedSettingOverride(SettingsType.LOCALE); + const isLocaleManaged = typeof managedLocaleRaw !== 'undefined'; + const managedLocale = isLocaleManaged ? normalizeManagedLocale(managedLocaleRaw) : undefined; if (!current) { - const savedLocale = settings.restore(SettingsType.LOCALE); + const savedLocale = isLocaleManaged + ? managedLocale + : settings.restore(SettingsType.LOCALE); const savedOverride = settings.restore(SettingsType.LOCALE_OVERRIDE); - const hasUserOverride = - typeof savedOverride === 'boolean' ? savedOverride : Boolean(savedLocale && savedLocale !== systemLocale); + const hasUserOverride = isLocaleManaged + ? true + : typeof savedOverride === 'boolean' + ? savedOverride + : Boolean(savedLocale && savedLocale !== systemLocale); current = savedLocale && hasUserOverride ? parseLocale(savedLocale) : systemLocale; return current; } + if (isLocaleManaged && managedLocale) { + current = managedLocale; + return current; + } + // If there’s no override and the system locale changed, update the cache const hasOverride = settings.restore(SettingsType.LOCALE_OVERRIDE) === true; if (!hasOverride && current !== systemLocale) { @@ -193,6 +226,9 @@ export const getText = ( }; export const setLocale = (locale: string): void => { + if (isSettingManaged(SettingsType.LOCALE)) { + return; + } current = parseLocale(locale); const systemLocale = getSystemLocale(); diff --git a/electron/src/main.ts b/electron/src/main.ts index d8251d8d8e4..128b80aeb5a 100644 --- a/electron/src/main.ts +++ b/electron/src/main.ts @@ -67,6 +67,7 @@ import * as lifecycle from './runtime/lifecycle'; import {OriginValidator} from './runtime/OriginValidator'; import {config} from './settings/config'; import {settings} from './settings/ConfigurationPersistence'; +import {getManagedConfig, getManagedConfigForRenderer} from './settings/ManagedConfig'; import {SettingsType} from './settings/SettingsType'; import {SingleSignOn} from './sso/SingleSignOn'; import {initMacAutoUpdater} from './update/macosAutoUpdater'; @@ -99,31 +100,64 @@ const customProtocolHandler = new CustomProtocolHandler(); // Config const argv = minimist(process.argv.slice(1)); +const {config: managedConfig} = getManagedConfig(); const fileBasedProxyConfig = settings.restore(SettingsType.PROXY_SERVER_URL); const logger = getLogger(path.basename(__filename)); const currentLocale = locale.getCurrent(); const startHidden = Boolean(argv[config.ARGUMENT.STARTUP] || argv[config.ARGUMENT.HIDDEN]); const customDownloadPath = settings.restore(SettingsType.DOWNLOAD_PATH); -const appHomePath = (path: string) => `${app.getPath('home')}\\${path}`; + +/** + * Resolves a managed download path under user home. Returns null if the path escapes home + * (e.g. ".." traversal) or is empty/whitespace. Used for user-content downloads on Windows and macOS. + * @param {string} relativePath - Path relative to user home (e.g. "Documents\\WireDownloads"). + * @returns {string | null} Resolved absolute path, or null if invalid or escaping home. + */ +const getSafeDownloadDirectory = (relativePath: string): string | null => { + const trimmed = relativePath?.trim(); + if (!trimmed) { + return null; + } + const home = app.getPath('home'); + const resolved = path.resolve(home, trimmed); + const normalizedHome = path.resolve(home); + const normalizedResolved = path.resolve(resolved); + if (!normalizedResolved.startsWith(normalizedHome + path.sep) && normalizedResolved !== normalizedHome) { + return null; + } + return normalizedResolved; +}; + const isInternalBuild = (): boolean => config.environment === 'internal'; -if (customDownloadPath) { - electronDl({ - directory: appHomePath(customDownloadPath), - saveAs: false, - onCompleted: () => { - dialog.showMessageBox({ - type: 'none', - icon: ICON, - title: locale.getText('enforcedDownloadComplete'), - message: locale.getText('enforcedDownloadMessage', { - path: appHomePath(customDownloadPath) ?? app.getPath('downloads'), - }), - buttons: [locale.getText('enforcedDownloadButton')], - }); - }, - }); +/** Managed download path is supported on Windows and macOS only (user-content download folder). */ +const useManagedDownloadPath = + (EnvironmentUtil.platform.IS_WINDOWS || EnvironmentUtil.platform.IS_MAC_OS) && + typeof customDownloadPath === 'string' && + customDownloadPath.length > 0; + +if (useManagedDownloadPath) { + const safeDir = getSafeDownloadDirectory(customDownloadPath); + if (safeDir) { + electronDl({ + directory: safeDir, + saveAs: false, + onCompleted: () => { + dialog.showMessageBox({ + type: 'none', + icon: ICON, + title: locale.getText('enforcedDownloadComplete'), + message: locale.getText('enforcedDownloadMessage', { + path: safeDir, + }), + buttons: [locale.getText('enforcedDownloadButton')], + }); + }, + }); + } else { + logger.warn('Managed download path rejected (path traversal or invalid).'); + } } if (argv[config.ARGUMENT.VERSION]) { @@ -133,13 +167,26 @@ if (argv[config.ARGUMENT.VERSION]) { logger.info(`Initializing ${config.name} v${config.version} ...`); -if (argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig) { +const managedProxyServerUrl = managedConfig.proxyServerUrl; +const proxyServerUrl = + typeof managedProxyServerUrl !== 'undefined' + ? managedProxyServerUrl + : argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig; +if (typeof managedProxyServerUrl !== 'undefined' && argv[config.ARGUMENT.PROXY_SERVER]) { + logger.info('Ignoring --proxy-server flag because managed proxy configuration is set.'); +} + +if (proxyServerUrl) { try { - proxyInfoArg = new URL(argv[config.ARGUMENT.PROXY_SERVER] || fileBasedProxyConfig); - if (!argv[config.ARGUMENT.PROXY_SERVER] && fileBasedProxyConfig) { + proxyInfoArg = new URL(proxyServerUrl); + if (!argv[config.ARGUMENT.PROXY_SERVER] && fileBasedProxyConfig && typeof managedProxyServerUrl === 'undefined') { logger.info(`Using proxy server URL from "init.json": ${fileBasedProxyConfig}`); app.commandLine.appendSwitch('proxy-server', fileBasedProxyConfig); } + if (typeof managedProxyServerUrl !== 'undefined') { + logger.info('Using proxy server URL from managed configuration.'); + app.commandLine.appendSwitch('proxy-server', managedProxyServerUrl); + } if (!/^(https?|socks[45]):$/.test(proxyInfoArg.protocol)) { throw new Error('Invalid protocol for the proxy server specified.'); } @@ -206,16 +253,28 @@ const bindIpcEvents = (): void => { ipcMain.on(EVENT_TYPE.ABOUT.SHOW, () => AboutWindow.showWindow()); ipcMain.handle(EVENT_TYPE.ACTION.GET_OG_DATA, (_event, url) => getOpenGraphDataAsync(url)); + /** Serves redacted managed config to renderer; avoids registry/OS access in webview process. */ + ipcMain.handle(EVENT_TYPE.ACTION.GET_MANAGED_CONFIG, () => getManagedConfigForRenderer()); + /** Download location: Windows/macOS only; blocked when managed; path validated to prevent traversal. */ ipcMain.on(EVENT_TYPE.ACTION.CHANGE_DOWNLOAD_LOCATION, (_event, downloadPath?: string) => { - if (EnvironmentUtil.platform.IS_WINDOWS) { - if (downloadPath) { - fs.ensureDirSync(appHomePath(downloadPath)); + if (!EnvironmentUtil.platform.IS_WINDOWS && !EnvironmentUtil.platform.IS_MAC_OS) { + return; + } + if (typeof managedConfig.downloadPath !== 'undefined') { + logger.info('Ignoring download path change because managed configuration is set.'); + return; + } + if (downloadPath) { + const safeDir = getSafeDownloadDirectory(downloadPath); + if (!safeDir) { + logger.warn('Download path change rejected (path traversal or invalid).'); + return; } - //save the downloadPath locally - settings.save(SettingsType.DOWNLOAD_PATH, downloadPath); - settings.persistToFile(); + fs.ensureDirSync(safeDir); } + settings.save(SettingsType.DOWNLOAD_PATH, downloadPath); + settings.persistToFile(); }); }; @@ -476,6 +535,7 @@ const handleAppEvents = (): void => { } Menu.setApplicationMenu(appMenu); + await systemMenu.applyManagedAutoLaunchSetting(); tray = new TrayHandler(); if (!EnvironmentUtil.platform.IS_MAC_OS) { tray.initTray(); @@ -490,6 +550,7 @@ const handleAppEvents = (): void => { } Menu.setApplicationMenu(appMenu); + await systemMenu.applyManagedAutoLaunchSetting(); tray = new TrayHandler(); if (!EnvironmentUtil.platform.IS_MAC_OS) { tray.initTray(); diff --git a/electron/src/menu/system.ts b/electron/src/menu/system.ts index 043fdc39c1e..f27782cfe73 100644 --- a/electron/src/menu/system.ts +++ b/electron/src/menu/system.ts @@ -32,6 +32,7 @@ import * as EnvironmentUtil from '../runtime/EnvironmentUtil'; import * as lifecycle from '../runtime/lifecycle'; import {config} from '../settings/config'; import {settings} from '../settings/ConfigurationPersistence'; +import {isSettingManaged} from '../settings/ManagedConfig'; import {SettingsType} from '../settings/SettingsType'; import {WindowManager} from '../window/WindowManager'; import {openExternal, sendToWebContents} from '../window/WindowUtil'; @@ -65,6 +66,7 @@ const createLanguageSubmenu = (): MenuItemConstructorOptions[] => { }; const localeTemplate: MenuItemConstructorOptions = { + enabled: !isSettingManaged(SettingsType.LOCALE), label: locale.getText('menuLocale'), submenu: createLanguageSubmenu(), }; @@ -82,6 +84,7 @@ const signOutTemplate: MenuItemConstructorOptions = { const spellingTemplate: MenuItemConstructorOptions = { checked: settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true), + enabled: !isSettingManaged(SettingsType.ENABLE_SPELL_CHECKING), click: () => toggleSpellChecking(), label: locale.getText('menuEnableSpellChecking'), type: 'checkbox', @@ -154,6 +157,7 @@ const showWireTemplate: MenuItemConstructorOptions = { const toggleMenuTemplate: MenuItemConstructorOptions = { checked: settings.restore(SettingsType.SHOW_MENU_BAR, true), + enabled: !isSettingManaged(SettingsType.SHOW_MENU_BAR), click: () => toggleMenuBar(), label: locale.getText('menuShowHide'), type: 'checkbox', @@ -174,7 +178,11 @@ const toggleFullScreenTemplate: MenuItemConstructorOptions = { const toggleAutoLaunchTemplate: MenuItemConstructorOptions = { checked: settings.restore(SettingsType.AUTO_LAUNCH, false), + enabled: !isSettingManaged(SettingsType.AUTO_LAUNCH), click: () => { + if (isSettingManaged(SettingsType.AUTO_LAUNCH)) { + return; + } const shouldAutoLaunch = !settings.restore(SettingsType.AUTO_LAUNCH); settings.save(SettingsType.AUTO_LAUNCH, shouldAutoLaunch); return shouldAutoLaunch ? launcher.enable() : launcher.disable(); @@ -401,6 +409,10 @@ const showRestartMessageBox = async () => { }; const changeLocale = async (language: locale.SupportedI18nLanguage): Promise => { + if (isSettingManaged(SettingsType.LOCALE)) { + logger.info('Ignoring locale change because managed configuration is set.'); + return; + } locale.setLocale(language); await showRestartMessageBox(); }; @@ -477,6 +489,10 @@ export const createMenu = (isFullScreen: boolean): Menu => { }; export const toggleMenuBar = (): void => { + if (isSettingManaged(SettingsType.SHOW_MENU_BAR)) { + logger.info('Ignoring menu bar toggle because managed configuration is set.'); + return; + } const mainBrowserWindow = WindowManager.getPrimaryWindow(); if (mainBrowserWindow) { @@ -488,11 +504,40 @@ export const toggleMenuBar = (): void => { }; export const toggleSpellChecking = async (): Promise => { + if (isSettingManaged(SettingsType.ENABLE_SPELL_CHECKING)) { + logger.info('Ignoring spell checking toggle because managed configuration is set.'); + return; + } const enableSpellChecking = settings.restore(SettingsType.ENABLE_SPELL_CHECKING, true); settings.save(SettingsType.ENABLE_SPELL_CHECKING, !enableSpellChecking); await showRestartMessageBox(); }; +export const applyManagedAutoLaunchSetting = async (): Promise => { + if (!isSettingManaged(SettingsType.AUTO_LAUNCH)) { + return; + } + if (config.name === 'Electron') { + return; + } + const shouldAutoLaunch = settings.restore(SettingsType.AUTO_LAUNCH, false); + try { + if (shouldAutoLaunch) { + await launcher.enable(); + } else { + await launcher.disable(); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const isLoginItemNotFound = + message.includes('-1728') || message.includes("Can't get login item") || message.includes('cannot be read'); + if (!shouldAutoLaunch && isLoginItemNotFound) { + return; + } + logger.error('Failed to apply managed auto-launch setting', error); + } +}; + export const registerGlobalShortcuts = (): void => { if (!EnvironmentUtil.platform.IS_LINUX) { const muteAccelerator = 'CmdOrCtrl+Alt+M'; diff --git a/electron/src/preload/preload-webview.ts b/electron/src/preload/preload-webview.ts index d532bf28572..5e48b10b84c 100644 --- a/electron/src/preload/preload-webview.ts +++ b/electron/src/preload/preload-webview.ts @@ -257,7 +257,7 @@ function reportWebappAVSVersion(): void { const _clearImmediate = clearImmediate; const _setImmediate = setImmediate; -process.once('loaded', () => { +process.once('loaded', async () => { global.clearImmediate = _clearImmediate; /** * @todo: This can be improved by polyfilling getDisplayMedia function @@ -276,7 +276,13 @@ process.once('loaded', () => { version: 1, }; global.environment = EnvironmentUtil; - global.desktopAppConfig = {version: EnvironmentUtil.app.DESKTOP_VERSION, supportsCallingPopoutWindow: true}; + /** Managed config from main only (redacted); no registry/OS access in renderer. */ + const managedConfig = await ipcRenderer.invoke(EVENT_TYPE.ACTION.GET_MANAGED_CONFIG); + global.desktopAppConfig = { + version: EnvironmentUtil.app.DESKTOP_VERSION, + supportsCallingPopoutWindow: true, + managedConfig, + }; global.openGraphAsync = getOpenGraphDataViaChannel; global.setImmediate = _setImmediate; }); diff --git a/electron/src/runtime/EnvironmentUtil.ts b/electron/src/runtime/EnvironmentUtil.ts index a092d5ae2da..b98233a507b 100644 --- a/electron/src/runtime/EnvironmentUtil.ts +++ b/electron/src/runtime/EnvironmentUtil.ts @@ -21,11 +21,20 @@ import minimist from 'minimist'; import {config} from '../settings/config'; import {settings} from '../settings/ConfigurationPersistence'; +import {getManagedConfig} from '../settings/ManagedConfig'; import {SettingsType} from '../settings/SettingsType'; const argv = minimist(process.argv.slice(1)); +const {config: managedConfig} = getManagedConfig(); const webappUrlSetting = settings.restore(SettingsType.CUSTOM_WEBAPP_URL); -const customWebappUrl: string | undefined = argv[config.ARGUMENT.ENV] || webappUrlSetting; +const managedWebappUrl = managedConfig.webappUrl; +const effectiveManagedWebappUrl = + typeof managedWebappUrl === 'string' && managedWebappUrl.trim() !== '' ? managedWebappUrl.trim() : undefined; +/** Environment is from settings/argv only; MDM cannot set environment (enterprises use test builds for that). */ +const ignoreEnvArgument = effectiveManagedWebappUrl !== undefined; +const customWebappUrl: string | undefined = ignoreEnvArgument + ? effectiveManagedWebappUrl + : argv[config.ARGUMENT.ENV] || webappUrlSetting; export enum ServerType { PRODUCTION = 'PRODUCTION', @@ -53,7 +62,7 @@ export const app = { DESKTOP_VERSION: config.version, IS_DEVELOPMENT: config.environment !== 'production', IS_PRODUCTION: config.environment === 'production', - UPDATE_URL_WIN: config.updateUrl, + UPDATE_URL_WIN: managedConfig.updateUrlWin || config.updateUrl, }; const isEnvVar = (envVar: string, value: string, caseSensitive = false): boolean => { @@ -94,7 +103,9 @@ export const setEnvironment = (env: ServerType): void => { */ function getWebappUrl() { const envUrl = currentEnvironment && webappEnvironments[currentEnvironment]?.url; - return customWebappUrl ?? envUrl ?? config.appBase; + const effectiveCustom = + typeof customWebappUrl === 'string' && customWebappUrl.trim() !== '' ? customWebappUrl.trim() : undefined; + return effectiveCustom ?? envUrl ?? config.appBase; } /** diff --git a/electron/src/settings/ConfigurationPersistence.ts b/electron/src/settings/ConfigurationPersistence.ts index 012fe256495..bfb1c66d3e5 100644 --- a/electron/src/settings/ConfigurationPersistence.ts +++ b/electron/src/settings/ConfigurationPersistence.ts @@ -22,7 +22,9 @@ import * as logdown from 'logdown'; import * as path from 'path'; +import {getManagedSettingOverride, isSettingManaged} from './ManagedConfig'; import {SchemaUpdater} from './SchemaUpdater'; +import {SettingsType} from './SettingsType'; import {getLogger} from '../logging/getLogger'; @@ -42,12 +44,20 @@ class ConfigurationPersistence { } delete(name: string): true { + if (isSettingManaged(name as SettingsType)) { + this.logger.warn(`Ignoring delete for managed setting "${name}".`); + return true; + } this.logger.info(`Deleting "${name}"`); delete global._ConfigurationPersistence[name]; return true; } save(name: string, value: T): true { + if (isSettingManaged(name as SettingsType)) { + this.logger.warn(`Ignoring save for managed setting "${name}".`); + return true; + } this.logger.info(`Saving "${name}" with value:`, value); global._ConfigurationPersistence[name] = value; return true; @@ -55,6 +65,10 @@ class ConfigurationPersistence { restore(name: string, defaultValue?: T): T { this.logger.info(`Restoring "${name}"`); + const managedOverride = getManagedSettingOverride(name as SettingsType); + if (typeof managedOverride !== 'undefined') { + return managedOverride; + } const value = global._ConfigurationPersistence[name]; return typeof value !== 'undefined' ? value : defaultValue; } diff --git a/electron/src/settings/ManagedConfig.test.main.ts b/electron/src/settings/ManagedConfig.test.main.ts new file mode 100644 index 00000000000..dc3eb1d0b22 --- /dev/null +++ b/electron/src/settings/ManagedConfig.test.main.ts @@ -0,0 +1,176 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import * as assert from 'assert'; + +import { + getManagedConfig, + getManagedConfigForRenderer, + getManagedSettingOverride, + isSettingManaged, + normalizeManagedConfig, + parseRegistryNumber, + parseRegistryValue, + redactProxyCredentials, + type ManagedConfigResult, + type ManagedConfigSource, +} from './ManagedConfig'; +import {SettingsType} from './SettingsType'; + +describe('ManagedConfig', () => { + describe('parseRegistryNumber', () => { + it('returns undefined for empty or whitespace', () => { + assert.strictEqual(parseRegistryNumber(''), undefined); + assert.strictEqual(parseRegistryNumber(' '), undefined); + }); + it('parses decimal numbers', () => { + assert.strictEqual(parseRegistryNumber('0'), 0); + assert.strictEqual(parseRegistryNumber('1'), 1); + assert.strictEqual(parseRegistryNumber('42'), 42); + }); + it('parses hex numbers', () => { + assert.strictEqual(parseRegistryNumber('0x0'), 0); + assert.strictEqual(parseRegistryNumber('0x1'), 1); + assert.strictEqual(parseRegistryNumber('0x2A'), 42); + }); + it('returns undefined for invalid input', () => { + assert.strictEqual(parseRegistryNumber('abc'), undefined); + assert.strictEqual(parseRegistryNumber('0xGH'), undefined); + }); + }); + + describe('parseRegistryValue', () => { + it('returns string value for expectedType string', () => { + assert.strictEqual( + parseRegistryValue({type: 'REG_SZ', value: ' https://app.wire.com '}, 'string'), + 'https://app.wire.com', + ); + }); + it('returns boolean for REG_DWORD', () => { + assert.strictEqual(parseRegistryValue({type: 'REG_DWORD', value: '0'}, 'boolean'), false); + assert.strictEqual(parseRegistryValue({type: 'REG_DWORD', value: '1'}, 'boolean'), true); + assert.strictEqual(parseRegistryValue({type: 'REG_DWORD', value: '0x0'}, 'boolean'), false); + assert.strictEqual(parseRegistryValue({type: 'REG_DWORD', value: '0x1'}, 'boolean'), true); + }); + it('returns boolean for string true/false', () => { + assert.strictEqual(parseRegistryValue({type: 'REG_SZ', value: 'true'}, 'boolean'), true); + assert.strictEqual(parseRegistryValue({type: 'REG_SZ', value: 'false'}, 'boolean'), false); + assert.strictEqual(parseRegistryValue({type: 'REG_SZ', value: 'yes'}, 'boolean'), true); + assert.strictEqual(parseRegistryValue({type: 'REG_SZ', value: 'no'}, 'boolean'), false); + }); + it('returns undefined for empty value', () => { + assert.strictEqual(parseRegistryValue({type: 'REG_SZ', value: ''}, 'string'), undefined); + }); + }); + + describe('redactProxyCredentials', () => { + it('strips username and password from URL', () => { + assert.strictEqual( + redactProxyCredentials('https://user:secret@proxy.example.com:8080/'), + 'https://proxy.example.com:8080/', + ); + }); + it('returns URL unchanged when no credentials', () => { + const url = 'https://proxy.example.com:8080/'; + assert.strictEqual(redactProxyCredentials(url), url); + }); + it('handles invalid URL by returning original', () => { + const invalid = 'not-a-url'; + assert.strictEqual(redactProxyCredentials(invalid), invalid); + }); + }); + + describe('normalizeManagedConfig', () => { + it('strips unknown keys; valid keys are preserved', () => { + const source: ManagedConfigSource = {platform: 'windows', location: 'HKCU\\...'}; + const raw = { + webappUrl: 'https://app.wire.com', + invalidKey: 'ignored', + }; + const result = normalizeManagedConfig(raw, source); + assert.strictEqual(result.config.webappUrl, 'https://app.wire.com'); + assert.ok(!('invalidKey' in result.config)); + }); + it('accepts valid keys', () => { + const source: ManagedConfigSource = {platform: 'macos'}; + const raw = { + webappUrl: 'https://app.wire.com', + disableAutoUpdate: true, + locale: 'en', + }; + const result = normalizeManagedConfig(raw, source); + assert.strictEqual(result.config.webappUrl, 'https://app.wire.com'); + assert.strictEqual(result.config.disableAutoUpdate, true); + assert.strictEqual(result.config.locale, 'en'); + assert.strictEqual(result.source.platform, 'macos'); + }); + }); + + describe('getManagedConfig with override (test seam)', () => { + const fixture: ManagedConfigResult = { + config: { + webappUrl: 'https://custom.example.com', + proxyServerUrl: 'https://proxy.example.com', + downloadPath: 'Downloads', + locale: 'de', + }, + source: {platform: 'windows'}, + }; + + afterEach(() => { + getManagedConfig({config: {}, source: {platform: 'unknown'}}); + }); + + it('getManagedSettingOverride returns managed value when override is set', () => { + getManagedConfig(fixture); + assert.strictEqual( + getManagedSettingOverride(SettingsType.CUSTOM_WEBAPP_URL), + 'https://custom.example.com', + ); + assert.strictEqual(getManagedSettingOverride(SettingsType.DOWNLOAD_PATH), 'Downloads'); + assert.strictEqual(getManagedSettingOverride(SettingsType.LOCALE), 'de'); + }); + it('getManagedSettingOverride returns undefined for unset setting', () => { + getManagedConfig(fixture); + assert.strictEqual(getManagedSettingOverride(SettingsType.PROXY_SERVER_URL), 'https://proxy.example.com'); + assert.strictEqual(getManagedSettingOverride(SettingsType.ENABLE_SPELL_CHECKING), undefined); + }); + it('isSettingManaged returns true for keys in override config', () => { + getManagedConfig(fixture); + assert.strictEqual(isSettingManaged(SettingsType.CUSTOM_WEBAPP_URL), true); + assert.strictEqual(isSettingManaged(SettingsType.LOCALE), true); + assert.strictEqual(isSettingManaged(SettingsType.ENABLE_SPELL_CHECKING), false); + }); + it('getManagedConfigForRenderer redacts proxy credentials', () => { + const withCredentials: ManagedConfigResult = { + config: { + ...fixture.config, + proxyServerUrl: 'https://user:secret@proxy.example.com:8080/', + }, + source: fixture.source, + }; + getManagedConfig(withCredentials); + const forRenderer = getManagedConfigForRenderer(); + assert.strictEqual(forRenderer.webappUrl, 'https://custom.example.com'); + assert.ok(forRenderer.proxyServerUrl?.includes('proxy.example.com')); + assert.ok(!forRenderer.proxyServerUrl?.includes('user')); + assert.ok(!forRenderer.proxyServerUrl?.includes('secret')); + }); + }); +}); diff --git a/electron/src/settings/ManagedConfig.ts b/electron/src/settings/ManagedConfig.ts new file mode 100644 index 00000000000..39ccf9a419b --- /dev/null +++ b/electron/src/settings/ManagedConfig.ts @@ -0,0 +1,440 @@ +/* + * Wire + * Copyright (C) 2025 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +/** + * Managed configuration (MDM) for enterprise deployment. + * Reads from Windows Registry (Group Policy / user) or macOS system preferences; + * validates with Joi; caches result for process lifetime. Config is loaded once + * in the main process only; renderer receives redacted config via IPC. + */ + +import Joi from '@hapi/joi'; +import * as Electron from 'electron'; + +import {execFileSync} from 'child_process'; +import * as path from 'path'; +import {URL} from 'url'; + +import {SettingsType} from './SettingsType'; + +import {getLogger} from '../logging/getLogger'; + +const logger = getLogger('ManagedConfig'); +const app = Electron.app || require('@electron/remote').app; +const systemPreferences = Electron.systemPreferences || require('@electron/remote').systemPreferences; + +export interface ManagedConfig { + webappUrl?: string; + updateUrlWin?: string; + proxyServerUrl?: string; + disableAutoUpdate?: boolean; + downloadPath?: string; + enableSpellChecking?: boolean; + showMenuBar?: boolean; + autoLaunch?: boolean; + locale?: string; +} + +export interface ManagedConfigSource { + platform: 'windows' | 'macos' | 'unknown'; + location?: string; +} + +export interface ManagedConfigResult { + config: ManagedConfig; + source: ManagedConfigSource; +} + +type ManagedKey = keyof ManagedConfig; +type ManagedValueType = 'string' | 'boolean'; + +export interface RegistryValue { + type: string; + value: string; +} + +const MANAGED_KEYS: Record = { + webappUrl: {type: 'string'}, + updateUrlWin: {type: 'string'}, + proxyServerUrl: {type: 'string'}, + disableAutoUpdate: {type: 'boolean'}, + downloadPath: {type: 'string'}, + enableSpellChecking: {type: 'boolean'}, + showMenuBar: {type: 'boolean'}, + autoLaunch: {type: 'boolean'}, + locale: {type: 'string'}, +}; + +const SETTINGS_MANAGED_MAP: Partial> = { + [SettingsType.CUSTOM_WEBAPP_URL]: 'webappUrl', + [SettingsType.PROXY_SERVER_URL]: 'proxyServerUrl', + [SettingsType.DOWNLOAD_PATH]: 'downloadPath', + [SettingsType.ENABLE_SPELL_CHECKING]: 'enableSpellChecking', + [SettingsType.SHOW_MENU_BAR]: 'showMenuBar', + [SettingsType.AUTO_LAUNCH]: 'autoLaunch', + [SettingsType.LOCALE]: 'locale', +}; + +const managedSchema = Joi.object({ + webappUrl: Joi.string() + .trim() + .uri({scheme: ['http', 'https']}), + updateUrlWin: Joi.string() + .trim() + .uri({scheme: ['http', 'https']}), + proxyServerUrl: Joi.string() + .trim() + .uri({scheme: ['http', 'https', 'socks4', 'socks5']}), + disableAutoUpdate: Joi.boolean(), + downloadPath: Joi.string().trim().min(1), + enableSpellChecking: Joi.boolean(), + showMenuBar: Joi.boolean(), + autoLaunch: Joi.boolean(), + locale: Joi.string() + .trim() + .pattern(/^[A-Za-z]{2,3}(-[A-Za-z]{2,3})?$/), +}).unknown(false); + +/** + * Cached managed config. Loaded once on first getManagedConfig() and not refreshed until process restart. + * MDM runtime updates (e.g. policy refresh on Windows, macOS defaults change) are not reflected until restart. + */ +let cachedConfig: ManagedConfigResult | null = null; + +/** + * Sanitize app name for use in Windows registry key path (alphanumeric only). + * @param {string} appName - Application name from app.getName(). + * @returns {string} Safe string for use in registry path. + */ +const sanitizeRegistryAppKey = (appName: string): string => { + const raw = (appName || 'Wire').trim(); + const safe = raw.replace(/[^A-Za-z0-9]/g, ''); + return safe.length > 0 ? safe : 'Wire'; +}; + +/** + * Build policy and user registry key paths for the app. + * @param {string} appName - Application name from app.getName(). + * @returns {Object} Object with policy and user key paths. + */ +const toRegistryPaths = (appName: string) => { + const registryAppKey = sanitizeRegistryAppKey(appName); + return { + policy: `HKCU\\Software\\Policies\\Wire\\${registryAppKey}`, + user: `HKCU\\Software\\Wire\\${registryAppKey}`, + }; +}; + +export const parseRegistryNumber = (rawValue: string): number | undefined => { + if (!rawValue) { + return undefined; + } + const trimmed = rawValue.trim(); + if (!trimmed) { + return undefined; + } + if (/^0x/i.test(trimmed)) { + const parsed = parseInt(trimmed, 16); + return Number.isNaN(parsed) ? undefined : parsed; + } + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +export const parseRegistryValue = ( + entry: RegistryValue, + expectedType: ManagedValueType, +): string | boolean | undefined => { + const rawValue = entry.value.trim(); + if (!rawValue) { + return undefined; + } + if (expectedType === 'string') { + return rawValue; + } + + if (entry.type === 'REG_DWORD') { + const numericValue = parseRegistryNumber(rawValue); + if (typeof numericValue === 'number') { + return numericValue !== 0; + } + } + + const normalized = rawValue.toLowerCase(); + if (['1', 'true', 'yes'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no'].includes(normalized)) { + return false; + } + return undefined; +}; + +/** + * Full path to reg.exe so we do not depend on PATH (avoids running a substituted binary). + * @returns {string} Absolute path to reg.exe. + */ +const getRegExePath = (): string => path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'reg.exe'); + +/** + * Queries a single Windows registry key. keyPath must be built from sanitizeRegistryAppKey to avoid injection. + * @param {string} keyPath - Registry path (e.g. HKCU\\Software\\Policies\\Wire\\Wire). + * @returns {Record} Map of value name to { type, value }. + */ +const queryRegistryKey = (keyPath: string): Record => { + try { + const output = execFileSync(getRegExePath(), ['query', keyPath], {encoding: 'utf8'}); + const values: Record = {}; + + for (const line of output.split(/\r?\n/)) { + // Match name and type only; take rest of line as value to avoid ReDoS from (.*)$ backtracking. + const prefixMatch = line.match(/^\s*([^\s]+)\s+(REG_\w+)\s+/); + if (!prefixMatch) { + continue; + } + const name = prefixMatch[1]; + const type = prefixMatch[2]; + const value = line.slice(prefixMatch[0].length).trim(); + values[name] = {type, value}; + } + + return values; + } catch (error) { + return {}; + } +}; + +/** + * Reads managed config from Windows Registry (policy then user). keyPath is built from sanitized app name. + * @returns {Object} Raw config and source metadata. + */ +const readWindowsRegistryConfig = (): {rawConfig: Record; source: ManagedConfigSource} => { + const appName = app?.getName?.() || 'Wire'; + const registryPaths = toRegistryPaths(appName); + const policyValues = queryRegistryKey(registryPaths.policy); + const userValues = queryRegistryKey(registryPaths.user); + const rawConfig: Record = {}; + const usedLocations = new Set(); + + (Object.keys(MANAGED_KEYS) as ManagedKey[]).forEach(key => { + const expectedType = MANAGED_KEYS[key].type; + const policyEntry = policyValues[key]; + const userEntry = userValues[key]; + const entry = policyEntry || userEntry; + + if (!entry) { + return; + } + + const parsedValue = parseRegistryValue(entry, expectedType); + if (typeof parsedValue === 'undefined') { + logger.warn(`Ignoring registry value "${key}" due to invalid format.`); + if (policyEntry) { + usedLocations.add(registryPaths.policy); + } else if (userEntry) { + usedLocations.add(registryPaths.user); + } + return; + } + + rawConfig[key] = parsedValue; + if (policyEntry) { + usedLocations.add(registryPaths.policy); + } else if (userEntry) { + usedLocations.add(registryPaths.user); + } + }); + + return { + rawConfig, + source: { + platform: 'windows', + location: Array.from(usedLocations).join(', ') || registryPaths.policy, + }, + }; +}; + +/** + * Reads managed config from macOS system preferences (CFPreferences / getUserDefault). + * @returns {Object} Raw config and source. + */ +const readMacManagedConfig = (): {rawConfig: Record; source: ManagedConfigSource} => { + const rawConfig: Record = {}; + + if (!systemPreferences?.getUserDefault) { + return { + rawConfig, + source: {platform: 'macos', location: app?.getName?.()}, + }; + } + + (Object.keys(MANAGED_KEYS) as ManagedKey[]).forEach(key => { + const expectedType = MANAGED_KEYS[key].type; + const prefType = expectedType === 'boolean' ? 'boolean' : 'string'; + const value = systemPreferences.getUserDefault(key, prefType); + if (value === null || typeof value === 'undefined') { + return; + } + // macOS: getUserDefault(key, 'string') returns "" for unset keys; treat as not set. + if (expectedType === 'string' && value === '') { + return; + } + rawConfig[key] = value; + }); + + return { + rawConfig, + source: { + platform: 'macos', + location: app?.getName?.(), + }, + }; +}; + +/** + * Validates and normalizes raw config; strips unknown keys and invalid values. Safe to call with untrusted input. + * @param {Record} rawConfig - Raw key-value map from registry or macOS preferences. + * @param {ManagedConfigSource} source - Source metadata (platform, location). + * @returns {ManagedConfigResult} Validated config and source. + */ +export const normalizeManagedConfig = ( + rawConfig: Record, + source: ManagedConfigSource, +): ManagedConfigResult => { + const validation = managedSchema.validate(rawConfig, { + abortEarly: false, + convert: true, + stripUnknown: true, + }); + + const config = {...validation.value} as ManagedConfig; + Object.entries(config).forEach(([key, value]) => { + if (typeof value === 'undefined') { + delete (config as Record)[key]; + } + }); + + if (validation.error) { + const invalidKeys = new Set(validation.error.details.map(detail => String(detail.path[0]))); + invalidKeys.forEach(key => { + const detail = validation.error!.details.find(d => String(d.path[0]) === key); + const reason = detail?.message ?? 'validation failure'; + logger.warn(`Ignoring managed config "${key}" due to validation failure: ${reason}.`); + delete (config as Record)[key]; + }); + } + + const configKeys = Object.keys(config); + if (configKeys.length > 0) { + logger.info(`Loaded managed config keys: ${configKeys.join(', ')}.`); + } + + return {config, source}; +}; + +/** + * Loads managed config from OS (Windows Registry or macOS preferences). + * @returns {ManagedConfigResult} Validated config and source, or empty config on unsupported platform. + */ +const loadManagedConfig = (): ManagedConfigResult => { + if (process.platform === 'win32') { + const {rawConfig, source} = readWindowsRegistryConfig(); + return normalizeManagedConfig(rawConfig, source); + } + + if (process.platform === 'darwin') { + const {rawConfig, source} = readMacManagedConfig(); + return normalizeManagedConfig(rawConfig, source); + } + + return { + config: {}, + source: {platform: 'unknown'}, + }; +}; + +/** + * Removes username and password from a proxy URL. Use before exposing config to the renderer + * so proxy credentials are never sent to untrusted context. + * @param {string} proxyServerUrl - Proxy URL (may contain user:password). + * @returns {string} URL with credentials stripped, or original if invalid. + */ +export const redactProxyCredentials = (proxyServerUrl: string): string => { + try { + const parsed = new URL(proxyServerUrl); + if (parsed.username || parsed.password) { + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } + } catch (error) { + return proxyServerUrl; + } + return proxyServerUrl; +}; + +/** + * Returns the current managed config. Loaded once and cached for process lifetime (no hot reload). + * @param {ManagedConfigResult} [override] - Test-only: when provided, used as the config and cached. + * @returns {ManagedConfigResult} Current managed config and source. + */ +export const getManagedConfig = (override?: ManagedConfigResult): ManagedConfigResult => { + if (override !== undefined) { + cachedConfig = override; + return override; + } + if (!cachedConfig) { + cachedConfig = loadManagedConfig(); + } + return cachedConfig; +}; + +/** + * Returns config safe for renderer: proxy URL is redacted so credentials are never exposed. + * @returns {ManagedConfig} Managed config with proxy credentials stripped. + */ +export const getManagedConfigForRenderer = (): ManagedConfig => { + const {config} = getManagedConfig(); + if (!config.proxyServerUrl) { + return config; + } + return { + ...config, + proxyServerUrl: redactProxyCredentials(config.proxyServerUrl), + }; +}; + +export const getManagedSettingOverride = (settingType: SettingsType): T | undefined => { + const managedKey = SETTINGS_MANAGED_MAP[settingType]; + if (!managedKey) { + return undefined; + } + const {config} = getManagedConfig(); + const value = config[managedKey]; + return typeof value === 'undefined' ? undefined : (value as T); +}; + +export const isSettingManaged = (settingType: SettingsType): boolean => { + const managedKey = SETTINGS_MANAGED_MAP[settingType]; + if (!managedKey) { + return false; + } + const {config} = getManagedConfig(); + return Object.prototype.hasOwnProperty.call(config, managedKey); +}; diff --git a/electron/src/types/globals.d.ts b/electron/src/types/globals.d.ts index a554976b502..1a9a36d56b7 100644 --- a/electron/src/types/globals.d.ts +++ b/electron/src/types/globals.d.ts @@ -24,6 +24,7 @@ import type {WebAppEvents} from '@wireapp/webapp-events'; import type {i18nStrings, SupportedI18nLanguage} from '../locale'; import type * as EnvironmentUtil from '../runtime/EnvironmentUtil'; +import type {ManagedConfig} from '../settings/ManagedConfig'; export declare global { /* eslint-disable no-var */ @@ -46,6 +47,7 @@ export declare global { var desktopAppConfig: { version: string; supportsCallingPopoutWindow?: boolean; + managedConfig?: ManagedConfig; }; /* eslint-enable no-var */ diff --git a/electron/src/update/macosAutoUpdater.ts b/electron/src/update/macosAutoUpdater.ts index 54f482cecba..641ff63fc3c 100644 --- a/electron/src/update/macosAutoUpdater.ts +++ b/electron/src/update/macosAutoUpdater.ts @@ -22,11 +22,17 @@ import {autoUpdater} from 'electron-updater'; import {getLogger} from '../logging/getLogger'; import {config} from '../settings/config'; +import {getManagedConfig} from '../settings/ManagedConfig'; const logger = getLogger('MacAutoUpdater'); const isInternalBuild = (): boolean => config.environment === 'internal'; export function initMacAutoUpdater(mainWindow: BrowserWindow): void { + const {config: managedConfig} = getManagedConfig(); + if (managedConfig.disableAutoUpdate) { + logger.log('Skipping auto-update: disabled by managed configuration'); + return; + } // Skip in dev if (process.env.NODE_ENV === 'development') { logger.log('Skipping auto-update in development'); diff --git a/electron/src/update/squirrel.ts b/electron/src/update/squirrel.ts index 9bf9df2f4bc..0fe87d203f0 100644 --- a/electron/src/update/squirrel.ts +++ b/electron/src/update/squirrel.ts @@ -32,6 +32,7 @@ import {getLogger} from '../logging/getLogger'; import * as EnvironmentUtil from '../runtime/EnvironmentUtil'; import * as lifecycle from '../runtime/lifecycle'; import {config, MINUTE_IN_MILLIS, HOUR_IN_MILLIS} from '../settings/config'; +import {getManagedConfig} from '../settings/ManagedConfig'; const logger = getLogger(path.basename(__filename)); @@ -42,6 +43,7 @@ const exeName = path.basename(process.execPath); const exePath = path.join(rootFolder, exeName); const windowsAppData = process.env.APPDATA; +const {config: managedConfig} = getManagedConfig(); if (!windowsAppData && EnvironmentUtil.platform.IS_WINDOWS) { logger.error('No Windows AppData directory found.'); @@ -103,6 +105,10 @@ async function spawnUpdate(args: string[]): Promise { } export async function installUpdate(): Promise { + if (managedConfig.disableAutoUpdate) { + logger.info('Skipping update install: disabled by managed configuration.'); + return; + } logger.info(`Checking for Windows updates at "${EnvironmentUtil.app.UPDATE_URL_WIN}" ...`); await spawnUpdate(['--update', EnvironmentUtil.app.UPDATE_URL_WIN]); } @@ -146,5 +152,10 @@ export async function handleSquirrelArgs(): Promise { } } + if (managedConfig.disableAutoUpdate) { + logger.info('Skipping update scheduler: disabled by managed configuration.'); + return; + } + await scheduleUpdate(); }