diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 61d67df64..8204826d3 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -195,13 +195,16 @@ function LoginPageContent() { // Only the desktop build sets these; a browser never shows them. const [desktopNotice, setDesktopNotice] = useState(null); const [desktopWaiting, setDesktopWaiting] = useState(false); - const [desktopPolicyNotice, setDesktopPolicyNotice] = useState(null); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; + // Also when the app sent someone here because their server came off the list. + const [desktopPolicyNotice, setDesktopPolicyNotice] = useState( + searchParams.get("notice") === "server_refused" ? tDesktopPolicy("server_refused") : null, + ); // When the mobile app launches the webmail in a browser tab it tacks on // these params. We grab them once at mount and stash them in a ref so any @@ -630,6 +633,11 @@ function LoginPageContent() { const signedOutOnPurpose = useRef(null); const startServerSideSso = useCallback(async () => { + // The device's OS policy, as for a password: nothing starts towards a refused server. + if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } setOauthLoading(true); try { const prefix = getPathPrefix(params.locale as string); @@ -916,6 +924,11 @@ function LoginPageContent() { * whichever server is selected so that "Sign in with Google" is one tap. */ const handleOAuthLogin = async (target?: PublicJmapServerEntry) => { + const oauthTarget = (target ?? selectedServer)?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl); + if (oauthTarget && !jmapServerAllowedByDesktopPolicy(oauthTarget, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } if (LITE_OAUTH_AVAILABLE && !target) { await startLiteOAuth(probeTarget); return; diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 93a7c7217..0a07003f1 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -319,6 +319,11 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } + // The device's OS policy refuses this server for now: nothing restores, and the + // cookie stays, so the account comes back with no sign-in if the policy does. + const refusedByDevice = devicePolicyServerRefusal(credentials.serverUrl); + if (refusedByDevice) return refusedByDevice; + const age = await checkSessionAge(cookieStore, slot, credentials); if ('maxHours' in age) return sessionTooOld(age.maxHours); diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index ba2d3d9ee..ff07f6b84 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -42,6 +42,7 @@ import { } from '@/lib/auth/session-age'; import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy'; import { clearCustomUpstream, readCustomUpstream, @@ -277,6 +278,10 @@ export async function PUT(request: NextRequest) { // waits it out. A 500 here would count toward signing the user out. let tokenResponse: Response; const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; + // The device's OS policy refuses this server for now. The cookies stay, so the + // account comes back with no sign-in if the policy does. + const refusedByDevice = upstream ? devicePolicyServerRefusal(upstream.serverUrl) : null; + if (refusedByDevice) return refusedByDevice; try { const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 8ae54ba71..505f868e6 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -6,6 +6,8 @@ import { Check, Plus, LogOut, Mails, Star, ChevronDown, AlertCircle, GripVertica import { useTranslations } from "next-intl"; import type { Mailbox } from "@/lib/jmap/types"; import { useAccountStore, type AccountEntry } from "@/stores/account-store"; +import { useDesktopPolicy } from "@/lib/desktop/policy"; +import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -95,7 +97,13 @@ export function AccountSwitcher({ const tCommon = useTranslations("common"); const ownMailboxes = useEmailStore((s) => s.mailboxes); - const accounts = useAccountStore((s) => s.accounts); + const storedAccounts = useAccountStore((s) => s.accounts); + const devicePolicy = useDesktopPolicy(); + // One on a server the device's OS policy refuses is stopped, and isn't on offer here. + const accounts = useMemo( + () => (devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts), + [storedAccounts, devicePolicy], + ); const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount); const reorderAccounts = useAccountStore((s) => s.reorderAccounts); // Read activeAccountId from authStore so the selector matches the actually-loaded diff --git a/components/settings/plugins-settings.tsx b/components/settings/plugins-settings.tsx index 105f782b4..3128b37fe 100644 --- a/components/settings/plugins-settings.tsx +++ b/components/settings/plugins-settings.tsx @@ -15,7 +15,7 @@ import { PluginPermissionList } from '@/components/plugins/plugin-permission-lis import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types'; import { effectiveSettings } from '@/lib/plugin-settings'; import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts'; -import { PLUGIN_POLICY_REFUSED_ERROR, useDesktopPolicy } from '@/lib/desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '@/lib/desktop/policy'; import { OrgLockedNote } from './org-locked-note'; import { lookup } from '@/lib/plugin-sandbox/plugin-i18n'; import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog'; @@ -68,7 +68,7 @@ export function PluginsSettings() { return null; } - const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && devicePolicy.PluginsMode.value !== 'off'; + const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && pluginUploadsAllowedByDesktopPolicy(devicePolicy); const requireApproval = isFeatureEnabled('requirePluginApproval'); const handleToggle = async (plugin: InstalledPlugin) => { diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index 391df08c9..d4b1b0817 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -42,12 +42,6 @@ - - - - - - @@ -55,25 +49,12 @@ - - - - - - - - - - - - - @@ -117,17 +98,6 @@ - - - - - - allowed - allowlist - off - - - @@ -135,13 +105,6 @@ - - - - - - - @@ -225,13 +188,6 @@ - - - - - - - @@ -239,13 +195,6 @@ - - - - - - - @@ -253,11 +202,11 @@ - - + + - + diff --git a/desktop-policies/README.txt b/desktop-policies/README.txt index 2e53d392a..5da7aad01 100644 --- a/desktop-policies/README.txt +++ b/desktop-policies/README.txt @@ -8,3 +8,8 @@ macOS: org.bulwarkmail.desktop.mobileconfig is a sample. Change it and deploy it Linux: policies.json is a sample for /etc/bulwark/policies.json. It must be owned by root and writable only by root (mode 644). Otherwise the app refuses to start. + +Limits: these settings stop people from changing them inside the app. The app is +not signed yet, so someone who can write to its folder or run their own copy can +patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC, +and install per machine with the MSI so standard users cannot write to the folder. diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index b9f25c3a9..fbacf62b3 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -8,7 +8,7 @@ Bulwark Bulwark Desktop Bulwark Desktop: Standardwerte, die Benutzer ändern können - Bulwark Desktop 1.0 und höher + Alle Versionen von Bulwark Desktop Verbindung Updates Plugins @@ -20,7 +20,7 @@ Direkte Verbindungen zu Mailservern zulassen Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten. Zugelassene Mailserver - Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt. + Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht. Bulwark-Server über unverschlüsseltes http zulassen Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen. Update-Kanal @@ -30,7 +30,7 @@ Automatisch aktualisieren Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern. Welche Plugins laufen dürfen - Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt. + Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt. Alle Plugins sind erlaubt Nur Plugins auf der Liste Keine Plugins @@ -54,30 +54,27 @@ Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam. Clientzertifikate automatisch wählen Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage. + Websites, die den Zertifikatspeicher des Systems verwenden dürfen + Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile. Auf dem Gerät behaltene E-Mails löschen - Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. + Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Zugelassene Mailserver - Zugelassene Mailserver Update-Kanal Update-Kanal Welche Plugins laufen dürfen - Welche Plugins laufen dürfen Plugin-IDs - Plugin-IDs Minuten Minuten - - Regeln - Regeln + Adressen diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 1bf30bacf..5c73606f8 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -8,7 +8,7 @@ Bulwark Bulwark Desktop Bulwark Desktop: defaults users can change - Bulwark Desktop 1.0 and later + All versions of Bulwark Desktop Connection Updates Plugins @@ -20,7 +20,7 @@ Allow direct connections to mail servers When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both. Allowed mail servers - The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused. + The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under "Bulwark server" as a default, must be on the list too. A forced one does not need to be. Allow a Bulwark server on plain http When enabled, the address in "Bulwark server" may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust. Update channel @@ -30,7 +30,7 @@ Update automatically When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates. Which plugins may run - Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again. + Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app's directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again. All plugins are allowed Only plugins on the list No plugins @@ -54,30 +54,27 @@ The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts. Choose client certificates automatically One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking. + Sites that may use the system certificate store + The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system's store, without the user being asked for each site. One per line. Delete mail kept on the device - Set this to a new value, such as today's date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. + Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. Allowed mail servers - Allowed mail servers Update channel Update channel Which plugins may run - Which plugins may run Plugin ids - Plugin ids Minutes Minutes - - Rules - Rules + Addresses diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig index fdb1b400f..474b9f20a 100644 --- a/desktop-policies/org.bulwarkmail.desktop.mobileconfig +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -57,6 +57,10 @@ {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}} + KeystoreAllowedOrigins + + https://mail.example.com + Recommended UpdateChannel diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json index 3c9113c58..965e905fb 100644 --- a/desktop-policies/policies.json +++ b/desktop-policies/policies.json @@ -22,6 +22,9 @@ "AutoSelectCertificateForUrls": [ "{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}" ], + "KeystoreAllowedOrigins": [ + "https://mail.example.com" + ], "Recommended": { "UpdateChannel": "stable" } diff --git a/lib/__tests__/plugin-device-policy.test.ts b/lib/__tests__/plugin-device-policy.test.ts index 815c56a3f..1b73ae377 100644 --- a/lib/__tests__/plugin-device-policy.test.ts +++ b/lib/__tests__/plugin-device-policy.test.ts @@ -52,7 +52,6 @@ const harness = vi.hoisted(() => { state.listeners.push(listener); return () => {}; }, - onWipeOfflineData: () => () => {}, }, }; return state; @@ -174,15 +173,22 @@ describe('the client gates', () => { expect(usePluginStore.getState().plugins.map((p) => p.id)).toEqual(['kept']); }); - it('installing a package is refused by the id in its manifest', async () => { + it('with a list nobody adds a package, even one naming a listed id', async () => { setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] }); - vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'refused-one' }, code: 'x' } as never); + vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'kept' }, code: 'x' } as never); const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); expect(result).toMatchObject({ success: false, reason: 'device-policy' }); expect(usePluginStore.getState().plugins).toEqual([]); expect(pluginStorage.saveCode).not.toHaveBeenCalled(); }); + it('a package the user added earlier under a listed id doesn\'t run', async () => { + setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['refused-one'] }); + usePluginStore.setState({ plugins: [plugin({ managed: false, enabled: false, status: 'installed' })] }); + await usePluginStore.getState().enablePlugin('refused-one'); + expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' }); + }); + it('turning one on is refused, and says why', async () => { setPolicy({ PluginsMode: 'off' }); usePluginStore.setState({ plugins: [plugin({ enabled: false, status: 'installed' })] }); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index a09c8511d..bb0c8fccd 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -106,7 +106,7 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { it('starts nothing the policy refuses: no frame, no download', async () => { const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema'); const snapshot = (forcedMachine: Record) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine }); - const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } }); + const bridgeWith = (current: ReturnType) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {} } }); window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] })); mocks.getCode.mockResolvedValue(CODE); try { @@ -119,6 +119,13 @@ describe('loadSandboxedPlugin under the OS policy of the device', () => { window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] })); await loadSandboxedPlugin(plugin()); expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' }); + unloadSandboxedPlugin('managed-plugin'); + + // The same id on a package of the user's own doesn't pass. + mocks.createBackgroundInstance.mockClear(); + await loadSandboxedPlugin(plugin({ managed: false })); + expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'device-policy' }); + expect(mocks.createBackgroundInstance).not.toHaveBeenCalled(); } finally { window.bulwarkDesktop = undefined; } diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index dd35e920d..37670f0ac 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -2,7 +2,8 @@ import { render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import type { BulwarkDesktopBridge } from '../types'; import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer, type PolicySnapshot } from '../policy-schema'; -import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, useDesktopPolicy } from '../policy'; +import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, pluginMayRunByDesktopPolicy, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '../policy'; +import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '../refused-accounts'; function snapshotOf(forcedMachine: PolicyLayer, recommendedMachine: PolicyLayer = {}): PolicySnapshot { return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine, recommendedMachine }); @@ -24,7 +25,6 @@ function setBridgePolicy(snapshot: PolicySnapshot) { listeners.push(listener); return () => { listeners = listeners.filter((l) => l !== listener); }; }, - onWipeOfflineData: () => () => {}, }, } as BulwarkDesktopBridge; } @@ -56,6 +56,34 @@ describe('in the app', () => { expect(jmapServerAllowedByDesktopPolicy('https://other.example')).toBe(false); }); + it('with a list, a package of the user\'s own can\'t pass by naming a listed id, and nobody can add one', () => { + setBridgePolicy(snapshotOf({ PluginsMode: 'allowlist', PluginAllowlist: ['good'] })); + expect(pluginMayRunByDesktopPolicy({ id: 'good', managed: true })).toBe(true); + expect(pluginMayRunByDesktopPolicy({ id: 'good' })).toBe(false); + expect(pluginMayRunByDesktopPolicy({ id: 'bad', managed: true })).toBe(false); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false); + setBridgePolicy(snapshotOf({})); + expect(pluginMayRunByDesktopPolicy({ id: 'anything' })).toBe(true); + expect(pluginUploadsAllowedByDesktopPolicy()).toBe(true); + }); + + it('plans what happens to accounts on a server taken off the list', () => { + const accounts = [ + { id: 'a', serverUrl: 'https://off.example', isConnected: true }, + { id: 'b', serverUrl: 'https://on.example', isConnected: false }, + { id: 'c', serverUrl: 'https://on.example', isConnected: true }, + ]; + const list = snapshotOf({ AllowedJmapServers: ['https://on.example'] }); + expect(accountRefusedByDesktopPolicy(accounts[0], list)).toBe(true); + expect(accountRefusedByDesktopPolicy(accounts[1], list)).toBe(false); + expect(planForRefusedAccounts(accounts, 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: 'c' }); + expect(planForRefusedAccounts(accounts, 'b', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: false, next: null }); + expect(planForRefusedAccounts([accounts[0]], 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: null }); + expect(planForRefusedAccounts(accounts, 'a', snapshotOf({})).refused).toEqual([]); + }); + it('a forced server hides the field for one of your own, unless direct connections are left on', () => { setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' })); expect(customServerAllowedByDesktopPolicy()).toBe(false); @@ -65,6 +93,9 @@ describe('in the app', () => { expect(customServerAllowedByDesktopPolicy()).toBe(false); setBridgePolicy(snapshotOf({}, { InstanceUrl: 'https://mail.example.com' })); expect(customServerAllowedByDesktopPolicy()).toBe(true); + // A recommendation can't open what a forced server closed. + setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' }, { AllowStandalone: 1 })); + expect(customServerAllowedByDesktopPolicy()).toBe(false); }); it('forced offline goes over the user, recommended under', () => { diff --git a/lib/desktop/__tests__/policy-enforcement.test.ts b/lib/desktop/__tests__/policy-enforcement.test.ts index cffd202a8..94f4c80ed 100644 --- a/lib/desktop/__tests__/policy-enforcement.test.ts +++ b/lib/desktop/__tests__/policy-enforcement.test.ts @@ -69,6 +69,23 @@ vi.mock('@/lib/security/url-guard', async (original) => ({ isPublicHttpUrl: async () => true, })); +const jar = vi.hoisted(() => ({ value: '' })); +const deleted = vi.hoisted(() => [] as string[]); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: () => (jar.value ? { value: jar.value } : undefined), + set: () => {}, + delete: (name: string) => deleted.push(name), + getAll: () => [], + }), +})); +vi.mock('@/lib/auth/crypto', () => ({ + encryptSession: () => 'x', + decryptSession: () => ({ serverUrl: 'https://other.example.org', username: 'a', password: 'b' }), + decryptPayload: () => null, + encryptPayload: () => 'x', +})); + const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); function setPolicy(policy: Partial | undefined) { @@ -198,6 +215,20 @@ describe('AllowedJmapServers on the sign-in routes', () => { expect(fetch).not.toHaveBeenCalled(); }); + it('a stored session on a refused server isn\'t restored, and the cookie stays', async () => { + setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); + jar.value = 'cookie'; + deleted.length = 0; + const { PUT } = await import('@/app/api/auth/session/route'); + const answer = await PUT(new NextRequest('http://localhost/api/auth/session', { + method: 'PUT', + headers: { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' }, + })); + expect(answer.status).toBe(403); + expect((await answer.json()).code).toBe('blocked_by_policy'); + expect(deleted).toEqual([]); + }); + it('the browser sign-in\'s lookup refuses it too', async () => { setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] }); const { resolveCustomUpstream } = await import('@/lib/oauth/custom-upstream'); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index be7261483..3411ad9ea 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -1,8 +1,11 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { DEFAULT_POLICY_SNAPSHOT, EMPTY_POLICY_LAYERS, POLICY_KEYS, + POLICY_CONSUMERS, POLICY_MAX_LIST_ITEMS, POLICY_SCHEMA, certificateMatchesRule, @@ -27,7 +30,7 @@ describe('the schema', () => { 'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate', 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', - 'AutoSelectCertificateForUrls', 'WipeOfflineData', + 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; @@ -46,6 +49,7 @@ describe('precedence', () => { recommendedUser: { AppLockTimeoutMinutes: 9, QuickComposeShortcut: 'Ctrl+Alt+M' }, }; const snapshot = resolvePolicies(all); + expect(resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'off' } })).PluginsMode).toMatchObject({ value: 'allowed', source: 'default' }); expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'forced-machine', locked: true }); expect(snapshot.AutoUpdate).toMatchObject({ value: false, source: 'forced-user', locked: true }); expect(snapshot.AppLockTimeoutMinutes).toMatchObject({ value: 5, source: 'recommended-machine', locked: false }); @@ -75,6 +79,17 @@ describe('precedence', () => { }); }); +describe('only a forced value counts for what decides what is allowed', () => { + it.each(['AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'PluginsMode', 'PluginAllowlist', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData'] as const)('%s', (key) => { + expect(POLICY_SCHEMA[key].forcedOnly).toBe(true); + }); + + it('a recommended AllowInsecureInstance can\'t let a forced http address through', () => { + const snapshot = resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' }, recommendedMachine: { AllowInsecureInstance: 1 } })); + expect(snapshot.InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' }); + }); +}); + describe('hostile values fail closed', () => { const junk: unknown[] = [{}, [], null, 'a'.repeat(100_000), Number.NaN, { toString: 'x' }, ['x'], -1]; @@ -88,7 +103,7 @@ describe('hostile values fail closed', () => { it('forced values that make no sense take the strict side, and a weaker level never speaks instead', () => { const snapshot = resolvePolicies(layers({ forcedMachine: { AllowStandalone: 'maybe', PluginsMode: 'banana', OfflineEnabled: 7, TelemetryAllowed: 'yes', AppLockRequired: 'no', AllowedJmapServers: 'https://a.example', QuickComposeShortcut: 'rm -rf' }, - recommendedMachine: { AllowStandalone: 1, PluginsMode: 'allowed' }, + recommendedMachine: { TelemetryAllowed: 1 }, })); expect(snapshot.AllowStandalone).toMatchObject({ value: false, locked: true, invalid: 'not_a_switch' }); expect(snapshot.PluginsMode).toMatchObject({ value: 'off', locked: true, invalid: 'unknown_value' }); @@ -101,9 +116,9 @@ describe('hostile values fail closed', () => { }); it('a recommendation that makes no sense is simply not one', () => { - const snapshot = resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'banana' }, recommendedUser: { PluginsMode: 'off' } })); - expect(snapshot.PluginsMode).toMatchObject({ value: 'off', source: 'recommended-user', locked: false }); - expect(snapshot.PluginsMode.invalid).toBeUndefined(); + const snapshot = resolvePolicies(layers({ recommendedMachine: { UpdateChannel: 'banana' }, recommendedUser: { UpdateChannel: 'beta' } })); + expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'recommended-user', locked: false }); + expect(snapshot.UpdateChannel.invalid).toBeUndefined(); }); it('a list over the cap is refused as a whole, and bad entries drop out one by one', () => { @@ -145,11 +160,19 @@ describe('InstanceUrl', () => { ['', 'empty'], [`https://${'a'.repeat(3000)}.com`, 'too_long'], ['https://exa\nmple.com', 'invalid_url'], + ['https://exa_mple.com', 'invalid_url'], + ['https://exa"mple.com', 'invalid_url'], [42, 'not_text'], ])('%s is refused', (raw, reason) => { expect(parseInstanceUrl(raw, false)).toEqual({ ok: false, reason }); }); + it('takes an international name as its punycode, and IP literals', () => { + expect(parseInstanceUrl('https://müller.de', false)).toEqual({ ok: true, value: 'https://xn--mller-kva.de/' }); + expect(parseInstanceUrl('https://10.0.0.5:8443', false)).toEqual({ ok: true, value: 'https://10.0.0.5:8443/' }); + expect(parseInstanceUrl('https://[::1]/', false)).toEqual({ ok: true, value: 'https://[::1]/' }); + }); + it('allows http only with AllowInsecureInstance, resolved from the policies', () => { expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' } })).InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' }); expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan', AllowInsecureInstance: 1 } })).InstanceUrl).toMatchObject({ value: 'http://bulwark.lan/' }); @@ -211,7 +234,7 @@ describe('sign-in lists', () => { expect(parsePolicyValue('AuthServerAllowlist', '*.example.com, intranet')).toEqual({ ok: true, value: '*.example.com,intranet' }); }); - it.each(['*', '*.example.com,*', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => { + it.each(['*', '*.example.com,*', '*.com', '*example.com', 'a.*.example.com', '*.', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => { expect(parsePolicyValue('AuthServerAllowlist', raw).ok).toBe(false); }); @@ -293,6 +316,35 @@ describe('AutoSelectCertificateForUrls', () => { }); }); +describe('KeystoreAllowedOrigins', () => { + it('takes https origins and drops the rest', () => { + expect(resolvePolicies(layers({ forcedMachine: { KeystoreAllowedOrigins: ['https://mail.example.com/', 'http://mail.example.com', 'https://mail.example.com/path', 'nonsense'] } })).KeystoreAllowedOrigins).toMatchObject({ value: ['https://mail.example.com'], dropped: 3 }); + }); +}); + +describe('every key has someone acting on it', () => { + it('names a consumer or who owes one', () => { + for (const key of POLICY_KEYS) { + const consumer = POLICY_CONSUMERS[key]; + expect(consumer, key).toBeTruthy(); + if ('pendingConsumer' in consumer) expect(consumer.pendingConsumer.length, key).toBeGreaterThan(5); + } + }); + + it('a consumer in this repo really reads the key', () => { + for (const key of POLICY_KEYS) { + const consumer = POLICY_CONSUMERS[key]; + if ('repo' in consumer && consumer.repo === 'webmail') expect(readFileSync(path.join(process.cwd(), consumer.file), 'utf8'), key).toContain(key); + } + }); + + // The coordinator flips this when step 7 is done: then no key may be waiting. + const ALL_WIRED = false; + it.runIf(ALL_WIRED)('nobody is waiting any more', () => { + expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]); + }); +}); + describe('defaults', () => { it('nothing set means nothing locked and nothing restricted', () => { for (const key of POLICY_KEYS) expect(DEFAULT_POLICY_SNAPSHOT[key]).toMatchObject({ source: 'default', locked: false }); diff --git a/lib/desktop/__tests__/policy-templates.test.ts b/lib/desktop/__tests__/policy-templates.test.ts index 972ea9a3d..3e4eb91e5 100644 --- a/lib/desktop/__tests__/policy-templates.test.ts +++ b/lib/desktop/__tests__/policy-templates.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { POLICY_KEYS, POLICY_RECOMMENDED_SUBKEY, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema'; +import { POLICY_KEYS, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema'; import { generateAdml, generateAdmx, generateMobileconfig, generatePoliciesJson, generatePolicyFiles } from '../policy-templates'; const NS = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions'; @@ -108,7 +108,7 @@ describe('the ADMX', () => { it('puts list policies under their own subkey with numbered values', () => { const lists = POLICY_KEYS.filter((key) => POLICY_SCHEMA[key].type === 'list'); - expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls']); + expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins']); for (const key of lists) { const forced = all(doc, 'policy').find((p) => p.getAttribute('name') === key)!; expect(forced.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${key}`); @@ -116,8 +116,8 @@ describe('the ADMX', () => { const list = all(forced, 'list')[0]; // Empty prefix is what makes Windows name the values 1, 2, 3. expect(list.getAttribute('valuePrefix')).toBe(''); - const recommended = all(doc, 'policy').find((p) => p.getAttribute('name') === `${key}_Recommended`)!; - expect(recommended.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${POLICY_RECOMMENDED_SUBKEY}\\${key}`); + // Lists decide what's allowed, so they can only be forced. + expect(all(doc, 'policy').some((p) => p.getAttribute('name') === `${key}_Recommended`)).toBe(false); } }); @@ -181,6 +181,8 @@ describe('the samples', () => { } expect(snapshot.InstanceUrl.value).toBe('https://mail.example.com/'); expect(snapshot.UpdateChannel.source).toBe('recommended-machine'); + expect(generatePolicyFiles()['README.txt']).toContain('AppLocker'); + expect(generateAdml('en')).not.toContain('1.0 and later'); expect(snapshot.WipeOfflineData.value).toBeNull(); }); diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 978973d71..3f45d884b 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -75,6 +75,7 @@ export interface PolicyValues { AuthServerAllowlist: string | null; AuthNegotiateDelegateAllowlist: string | null; AutoSelectCertificateForUrls: CertificateRule[]; + KeystoreAllowedOrigins: string[]; WipeOfflineData: string | null; } @@ -84,7 +85,11 @@ interface PolicyBase { category: PolicyCategory; title: PolicyText; description: PolicyText; - /** Only a forced value counts: there's no such thing as a recommended wipe. */ + /** + * Only a forced value counts. Anything that decides what's allowed: it has no user + * choice to sit under, so a "recommended" one would act as forced without being + * shown as such, and only a forced value may loosen a forced one. + */ forcedOnly?: boolean; } @@ -123,7 +128,7 @@ export type PolicyDefinition = PolicyBase & } | { type: 'list'; - item: 'server' | 'plugin' | 'certificate'; + item: 'server' | 'plugin' | 'certificate' | 'origin'; default: readonly never[] | null; label: PolicyText; sample: readonly string[]; @@ -147,6 +152,7 @@ export const POLICY_SCHEMA: Record = { }, AllowStandalone: { category: 'connection', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -159,6 +165,7 @@ export const POLICY_SCHEMA: Record = { }, AllowedJmapServers: { category: 'connection', + forcedOnly: true, type: 'list', item: 'server', default: null, @@ -166,12 +173,13 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, title: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' }, description: { - en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused.', - de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt.', + en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under "Bulwark server" as a default, must be on the list too. A forced one does not need to be.', + de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht.', }, }, AllowInsecureInstance: { category: 'connection', + forcedOnly: true, type: 'boolean', default: false, onInvalid: false, @@ -212,6 +220,7 @@ export const POLICY_SCHEMA: Record = { }, PluginsMode: { category: 'plugins', + forcedOnly: true, type: 'enum', default: 'allowed', onInvalid: 'off', @@ -223,12 +232,13 @@ export const POLICY_SCHEMA: Record = { ], title: { en: 'Which plugins may run', de: 'Welche Plugins laufen dürfen' }, description: { - en: 'Whether plugins can be installed and run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.', - de: 'Ob Plugins installiert und ausgeführt werden dürfen. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.', + en: 'Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app\'s directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.', + de: 'Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.', }, }, PluginAllowlist: { category: 'plugins', + forcedOnly: true, type: 'list', item: 'plugin', default: [], @@ -318,6 +328,7 @@ export const POLICY_SCHEMA: Record = { }, AuthServerAllowlist: { category: 'signIn', + forcedOnly: true, type: 'string', format: 'hostList', default: null, @@ -332,6 +343,7 @@ export const POLICY_SCHEMA: Record = { }, AuthNegotiateDelegateAllowlist: { category: 'signIn', + forcedOnly: true, type: 'string', format: 'hostList', default: null, @@ -346,6 +358,7 @@ export const POLICY_SCHEMA: Record = { }, AutoSelectCertificateForUrls: { category: 'signIn', + forcedOnly: true, type: 'list', item: 'certificate', default: [], @@ -357,6 +370,20 @@ export const POLICY_SCHEMA: Record = { de: 'Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.', }, }, + KeystoreAllowedOrigins: { + category: 'signIn', + forcedOnly: true, + type: 'list', + item: 'origin', + default: [], + sample: ['https://mail.example.com'], + label: { en: 'Addresses', de: 'Adressen' }, + title: { en: 'Sites that may use the system certificate store', de: 'Websites, die den Zertifikatspeicher des Systems verwenden dürfen' }, + description: { + en: 'The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system\'s store, without the user being asked for each site. One per line.', + de: 'Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.', + }, + }, WipeOfflineData: { category: 'security', type: 'string', @@ -368,8 +395,8 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Wipe code', de: 'Löschcode' }, title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' }, description: { - en: 'Set this to a new value, such as today\'s date, to delete all mail the app keeps on the device. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', - de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alle E-Mails zu löschen, die die App auf dem Gerät behält. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', + en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', + de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', }, }, }; @@ -436,6 +463,9 @@ export function parseInstanceUrl(raw: unknown, allowInsecure: boolean): Parsed POLICY_MAX_STRING || CONTROL.test(raw)) return null; + try { + const url = new URL(raw.trim()); + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null; + if (url.pathname !== '/' || !/^[a-z0-9.-]+$/.test(url.hostname)) return null; + return url.origin; + } catch { + return null; + } +} + const PLUGIN_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/; export function parsePluginId(raw: unknown): string | null { @@ -526,8 +569,9 @@ function parseHostList(raw: unknown): Parsed { if (!HOST_LIST.test(text)) return fail('invalid_host_list'); const hosts = text.split(',').filter(Boolean); if (hosts.length === 0 || hosts.length > POLICY_MAX_LIST_ITEMS) return fail('invalid_host_list'); - // `*` alone would hand Windows credentials to any site that asks. - if (hosts.some((host) => host === '*' || host === '*.')) return fail('invalid_host_list'); + // A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com` + // would hand Windows credentials to any site that asks. + if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list'); return { ok: true, value: hosts.join(',') }; } @@ -678,6 +722,8 @@ export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = f return parseList(raw, parsePluginId); case 'certificate': return parseList(raw, parseCertificateRule); + case 'origin': + return parseList(raw, parseHttpsOrigin); } } return fail('unknown_policy'); @@ -756,6 +802,34 @@ export function effectivePolicyValue(entry: { value: T; locked: boolean }, ch export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS); +/** + * Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's + * read the named file for the key. `pendingConsumer` names who still has to wire one. + */ +export type PolicyConsumer = { repo: 'shell' | 'webmail'; file: string } | { pendingConsumer: string }; + +export const POLICY_CONSUMERS: Record = { + InstanceUrl: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowStandalone: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowedJmapServers: { repo: 'shell', file: 'src/main/policy-start.ts' }, + AllowInsecureInstance: { repo: 'shell', file: 'src/main/policy-start.ts' }, + PluginsMode: { repo: 'webmail', file: 'lib/desktop/policy.ts' }, + PluginAllowlist: { repo: 'webmail', file: 'lib/desktop/policy.ts' }, + OfflineEnabled: { repo: 'webmail', file: 'lib/offline/scope.ts' }, + WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, + AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + AppLockRequired: { pendingConsumer: 'step 7, app lock' }, + AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' }, + TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' }, + CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' }, + QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' }, + AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' }, + KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' }, +}; + /** The part the embedded server enforces. Sent to it by message. */ export interface ServerPolicy { pluginsMode: PluginsMode; diff --git a/lib/desktop/policy-templates.ts b/lib/desktop/policy-templates.ts index bd67c4dd4..c9e4b5d09 100644 --- a/lib/desktop/policy-templates.ts +++ b/lib/desktop/policy-templates.ts @@ -141,7 +141,7 @@ const INTRO: PolicyText = { en: 'Settings for the Bulwark desktop app.', de: 'Einstellungen für die Bulwark-Desktop-App.', }; -const SUPPORTED_TEXT: PolicyText = { en: 'Bulwark Desktop 1.0 and later', de: 'Bulwark Desktop 1.0 und höher' }; +const SUPPORTED_TEXT: PolicyText = { en: 'All versions of Bulwark Desktop', de: 'Alle Versionen von Bulwark Desktop' }; const RECOMMENDED_TEXT: PolicyText = { en: 'Bulwark Desktop: defaults users can change', de: 'Bulwark Desktop: Standardwerte, die Benutzer ändern können' }; function presentationFor(key: PolicyKey, id: string, language: Language): string | null { @@ -300,6 +300,11 @@ export function generatePolicyFiles(): Record { `Linux: policies.json is a sample for ${POLICY_LINUX_FILE}. It must be owned by root`, 'and writable only by root (mode 644). Otherwise the app refuses to start.', '', + 'Limits: these settings stop people from changing them inside the app. The app is', + 'not signed yet, so someone who can write to its folder or run their own copy can', + 'patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC,', + 'and install per machine with the MSI so standard users cannot write to the folder.', + '', ].join('\n'), }; } diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 3c3fad5a3..5e9ec2f2f 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -48,14 +48,6 @@ export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => vo return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {}); } -/** - * Asks to hear about a forced WipeOfflineData value. The store's wipe goes here: - * when the handler's promise resolves, the shell remembers the value for good. - */ -export function onWipeOfflineData(handler: (nonce: string) => Promise): () => void { - return getDesktopBridge()?.policy?.onWipeOfflineData?.(handler) ?? (() => {}); -} - const serverSnapshot = () => DEFAULT_POLICY_SNAPSHOT; /** The policies, live, for a component. */ @@ -76,6 +68,21 @@ export function pluginAllowedByDesktopPolicy(id: string, snapshot: PolicySnapsho return pluginAllowedByPolicy(snapshot.PluginsMode.value, snapshot.PluginAllowlist.value, id); } +/** + * Whether a plugin may run. A package names its own id, so with a list only what an + * admin deployed (`managed`) can match it: anyone could build a package called the + * same as a listed plugin. + */ +export function pluginMayRunByDesktopPolicy(plugin: { id: string; managed?: boolean }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + if (snapshot.PluginsMode.value === 'allowed') return true; + return plugin.managed === true && pluginAllowedByDesktopPolicy(plugin.id, snapshot); +} + +/** With a list or none, nobody adds a package of their own. Same reason. */ +export function pluginUploadsAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return snapshot.PluginsMode.value === 'allowed'; +} + export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { return jmapServerAllowed(snapshot.AllowedJmapServers.value, serverUrl); } @@ -87,7 +94,7 @@ export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: Po */ export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean { const { AllowStandalone, InstanceUrl } = snapshot; - if (AllowStandalone.source !== 'default') return AllowStandalone.value; + if (AllowStandalone.locked) return AllowStandalone.value; return !InstanceUrl.locked; } diff --git a/lib/desktop/refused-accounts.ts b/lib/desktop/refused-accounts.ts new file mode 100644 index 000000000..26789a570 --- /dev/null +++ b/lib/desktop/refused-accounts.ts @@ -0,0 +1,39 @@ +/** + * Which accounts the device's OS policy refuses, and what follows from it. Pure, so + * the auth store only has to carry it out: a server taken off AllowedJmapServers stops + * its accounts without signing them out. + */ + +import { getDesktopPolicy, jmapServerAllowedByDesktopPolicy } from './policy'; +import type { PolicySnapshot } from './policy-schema'; + +interface AccountLike { + id: string; + serverUrl: string; + isConnected?: boolean; +} + +export function accountRefusedByDesktopPolicy(account: { serverUrl: string }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean { + return !jmapServerAllowedByDesktopPolicy(account.serverUrl, snapshot); +} + +export interface RefusedPlan { + refused: string[]; + /** To stop now: the refused ones, in the order stored. */ + stop: string[]; + activeRefused: boolean; + /** Where the active account moves: a connected one first, as a sign-out does. */ + next: string | null; +} + +export function planForRefusedAccounts(accounts: readonly AccountLike[], activeId: string | null, snapshot: PolicySnapshot): RefusedPlan { + const refused = accounts.filter((account) => accountRefusedByDesktopPolicy(account, snapshot)).map((account) => account.id); + const allowed = accounts.filter((account) => !refused.includes(account.id)); + const activeRefused = !!activeId && refused.includes(activeId); + return { + refused, + stop: refused, + activeRefused, + next: activeRefused ? (allowed.find((account) => account.isConnected) ?? allowed[0])?.id ?? null : null, + }; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 04d9dac4e..88c16b99a 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -117,12 +117,6 @@ export interface DesktopPolicyBridge { /** What's in force now. Synchronous: a page can read it in its first render. */ get: () => PolicySnapshot; onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void; - /** - * A forced WipeOfflineData value the app hasn't acted on. Delete local mail in the - * handler: when its promise resolves the value counts as done and never comes back, - * also after a restart. If it rejects, the next launch asks again. - */ - onWipeOfflineData: (handler: (nonce: string) => Promise) => () => void; } export interface BulwarkDesktopBridge { diff --git a/lib/oauth/custom-upstream.ts b/lib/oauth/custom-upstream.ts index 24d08396e..b27268959 100644 --- a/lib/oauth/custom-upstream.ts +++ b/lib/oauth/custom-upstream.ts @@ -37,8 +37,12 @@ export function customUpstreamCookieName(slot: number): string { /** Null when the address isn't one the sign-in routes would accept right now. */ export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise { - if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; if (mailServerRefusedByDevicePolicy(serverUrl)) return null; + return checkedUpstream(serverUrl, clientId); +} + +async function checkedUpstream(serverUrl: unknown, clientId: unknown): Promise { + if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; await configManager.ensureLoaded(); const servers = parseJmapServers(configManager.get('jmapServers', [])); const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get('jmapServerUrl', ''), servers) !== null; @@ -65,7 +69,9 @@ export async function readCustomUpstream(cookieStore: CookieStore, slot: number) } catch { return null; } - return payload ? resolveCustomUpstream(payload.s, payload.c) : null; + // Without the device's policy: a sign-out still has to revoke its token at a server + // that came off the list. A refresh checks the policy itself. + return payload ? checkedUpstream(payload.s, payload.c) : null; } export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 8966c614b..a02b826d7 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -39,6 +39,7 @@ import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; +import { accountRefusedByDesktopPolicy } from '@/lib/desktop/refused-accounts'; import { generateMessageId } from '@/lib/jmap/client'; import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; @@ -678,6 +679,9 @@ export function scheduleSync(account: string, delayMs = 2_000): void { const syncsRunning = new Set(); export async function runSync(account: string): Promise { + // Stopped by the device's OS policy (stores/auth-store.ts): its data waits. + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (entry && accountRefusedByDesktopPolicy(entry)) return; if (syncsRunning.has(account) || !offlineActive(account)) return; const client = onlineClientFor(account); if (!client || isOfflineNow(client.getServerUrl())) return; diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 000f2c536..bc30cbcff 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -39,7 +39,7 @@ import { hookArgsFor } from './hook-payload'; import { rememberDeclaredHooks } from '../plugin-send-guards'; import type { SlotName } from '../plugin-types'; import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts'; -import { PLUGIN_POLICY_REFUSED_ERROR, pluginAllowedByDesktopPolicy } from '../desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, pluginMayRunByDesktopPolicy } from '../desktop/policy'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -232,7 +232,7 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { // The device's OS policy. On an instance this is the only gate there is: the // instance's server knows nothing of this device's policy. - if (!pluginAllowedByDesktopPolicy(plugin.id)) { + if (!pluginMayRunByDesktopPolicy(plugin)) { storeAccessor?.setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR); return; } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..6307b1e0d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -21,6 +21,8 @@ import { getDesktopBridge } from '@/lib/desktop/bridge'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; +import { getDesktopPolicy, onDesktopPolicyChange } from '@/lib/desktop/policy'; +import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '@/lib/desktop/refused-accounts'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup'; import { forgetPrincipals } from '@/lib/delegation/known-principals'; @@ -2470,7 +2472,7 @@ export const useAuthStore = create()( // Prefer an account that is signed in here: one restored from the // account list backup (or otherwise not connected) has no client, and // picking it signs out every account left. - const remainingAccounts = useAccountStore.getState().accounts; + const remainingAccounts = useAccountStore.getState().accounts.filter((account) => !accountRefusedByDesktopPolicy(account)); const nextAccount = wasDemoMode ? undefined : remainingAccounts.find((a) => clients.has(a.id)) ?? remainingAccounts[0]; @@ -2652,7 +2654,7 @@ export const useAuthStore = create()( const accountStore = useAccountStore.getState(); const targetAccount = accountStore.getAccountById(accountId); - if (!targetAccount) return; + if (!targetAccount || accountRefusedByDesktopPolicy(targetAccount)) return; // Restored from the account list backup and never signed in here: // there is no session to resume, so ask for the password instead. if (targetAccount.awaitingSignIn && !clients.has(accountId)) { @@ -2876,7 +2878,15 @@ export const useAuthStore = create()( checkAuth: async () => { const accountStore = useAccountStore.getState(); - let accounts = accountStore.accounts; + // An account on a server the device's OS policy refuses stays stored, with its + // cookies and local data, and stays out of everything until it's allowed again. + const everyAccount = accountStore.accounts; + let accounts = everyAccount.filter((account) => !accountRefusedByDesktopPolicy(account)); + if (everyAccount.length > 0 && accounts.length === 0) { + set({ isLoading: false, isAuthenticated: false, client: null, activeAccountId: null }); + if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) leaveForRefusedServer(); + return; + } // If every account is a demo account, re-initialize demo mode // instead of trying to restore a server session (which doesn't exist). @@ -2940,8 +2950,9 @@ export const useAuthStore = create()( // Determine which account to activate first const defaultAccount = accountStore.getDefaultAccount(); - const activeId = get().activeAccountId; - const targetId = activeId || defaultAccount?.id || accounts[0].id; + const usable = (id: string | null | undefined) => (id && accounts.some((account) => account.id === id) ? id : null); + const activeId = usable(get().activeAccountId); + const targetId = activeId || usable(defaultAccount?.id) || accounts[0].id; // Restore one account: decrypt the stored credential/token, connect, // and sync the passthrough auth context. The context write never @@ -3665,6 +3676,56 @@ useAuthStore.subscribe((state, prev) => { }); }); +/** Off to the login page, which says why. The account stays, for when the policy allows it again. */ +function leaveForRefusedServer(): void { + if (isPopoutWindow()) { + closePopoutWindow(); + return; + } + replaceWindowLocation(`${getLocaleLoginPath()}?notice=server_refused`); +} + +const stoppedForPolicy = new Set(); + +/** + * The device's OS policy took a server off its list, or put one back. A refused account + * stops: no client, no push, no renewals, and with no client its store has nothing to + * sync through. It isn't signed out: cookies, outbox and mail stay. The active one moves + * to the next allowed account, or to the login page. + */ +export function applyDesktopServerPolicy(): void { + const auth = useAuthStore.getState(); + const accounts = useAccountStore.getState().accounts; + const plan = planForRefusedAccounts(accounts, auth.activeAccountId, getDesktopPolicy()); + for (const id of plan.stop) { + stoppedForPolicy.add(id); + clearRefreshTimer(id); + const client = clients.get(id); + if (client) { + forgetCalendarSubscriptions(client); + try { client.disconnect(); } catch { /* noop */ } + clients.delete(id); + } + useAccountStore.getState().updateAccount(id, { isConnected: false }); + } + const returned = [...stoppedForPolicy].filter((id) => !plan.refused.includes(id)); + for (const id of returned) stoppedForPolicy.delete(id); + + if (plan.activeRefused) { + if (plan.next) void auth.switchAccount(plan.next); + else { + useAuthStore.setState({ client: null, isAuthenticated: false }); + leaveForRefusedServer(); + } + return; + } + // Allowed again. With nobody signed in the start-up path restores it from its cookies; + // otherwise it shows in the switcher again and connects when switched to. + if (returned.length > 0 && !auth.client) window.location.replace('/'); +} + +if (typeof window !== 'undefined') onDesktopPolicyChange(applyDesktopServerPolicy); + // See canEnterLoginInPlace. let signedInSincePageLoad = useAuthStore.getState().isAuthenticated; useAuthStore.subscribe((state) => { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index f197e8dc2..913662e62 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -27,7 +27,7 @@ import { fetchServerPluginList } from '@/lib/server-plugin-list'; import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; import { IS_LITE } from '@/lib/lite'; -import { PLUGIN_POLICY_REFUSED_ERROR, onDesktopPolicyChange, pluginAllowedByDesktopPolicy } from '@/lib/desktop/policy'; +import { PLUGIN_POLICY_REFUSED_ERROR, onDesktopPolicyChange, pluginAllowedByDesktopPolicy, pluginMayRunByDesktopPolicy, pluginUploadsAllowedByDesktopPolicy } from '@/lib/desktop/policy'; import { onFullSignOut } from '@/lib/sign-out-cleanup'; import { forgetDeclaredHooks, setSendGuardSource } from '@/lib/plugin-send-guards'; @@ -144,6 +144,7 @@ export const usePluginStore = create()( installPlugin: async (file: File) => { const policy = usePolicyStore.getState(); + if (!pluginUploadsAllowedByDesktopPolicy()) return { success: false, reason: 'device-policy' }; if (!policy.isFeatureEnabled('pluginsUploadEnabled')) { return { success: false, reason: 'uploads-disabled' }; } @@ -153,9 +154,6 @@ export const usePluginStore = create()( } const { manifest, code } = result; - if (!pluginAllowedByDesktopPolicy(manifest.id)) { - return { success: false, reason: 'device-policy' }; - } const { plugins } = get(); // Check for duplicate @@ -238,7 +236,7 @@ export const usePluginStore = create()( const plugin = plugins.find(p => p.id === id); if (!plugin) return; - if (!pluginAllowedByDesktopPolicy(id)) { + if (!pluginMayRunByDesktopPolicy(plugin)) { set(state => ({ plugins: state.plugins.map(p => (p.id === id ? { ...p, status: 'error', error: PLUGIN_POLICY_REFUSED_ERROR } : p)) })); return; } @@ -842,7 +840,7 @@ async function userPluginMayRun( function applyDesktopPluginPolicy(): void { const { plugins, setPluginStatus } = usePluginStore.getState(); for (const plugin of plugins) { - const allowed = pluginAllowedByDesktopPolicy(plugin.id); + const allowed = pluginMayRunByDesktopPolicy(plugin); if (!allowed && getActivePlugin(plugin.id)) { deactivatePlugin(plugin.id); setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR);