mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
fix: hold forced policies against spoofed plugins, recommended values and stopped servers
A forced value could be got around from inside the app. Only a forced value may now decide what's allowed: the keys that do are forced-only, so a recommended one no longer acts as forced. With a plugin list a package can name any id, so uploads are refused and only managed plugins can match. A server taken off AllowedJmapServers stops its accounts without signing out: client, push and sync stop, the switcher drops them, restore and refresh answer 403 with the cookie kept, and listing it again resumes with no sign-in. OAuth and SSO starts check the list too. - `policy.onWipeOfflineData` is gone from the bridge: main runs the wipe - `KeystoreAllowedOrigins` added, and `POLICY_CONSUMERS` names who acts on each key or who still owes it - a Bulwark server's name must be plain after parsing, a wildcard in the auth lists needs a domain of two labels
This commit is contained in:
@@ -195,13 +195,16 @@ function LoginPageContent() {
|
||||
// Only the desktop build sets these; a browser never shows them.
|
||||
const [desktopNotice, setDesktopNotice] = useState<string | null>(null);
|
||||
const [desktopWaiting, setDesktopWaiting] = useState(false);
|
||||
const [desktopPolicyNotice, setDesktopPolicyNotice] = useState<string | null>(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<string | null>(
|
||||
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<boolean | null>(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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -42,12 +42,6 @@
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AllowStandalone_Recommended" class="Both" displayName="$(string.AllowStandalone)" explainText="$(string.AllowStandalone_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AllowStandalone">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AllowedJmapServers" class="Both" displayName="$(string.AllowedJmapServers)" explainText="$(string.AllowedJmapServers_Explain)" presentation="$(presentation.AllowedJmapServers)" key="Software\Policies\Bulwark\Desktop\AllowedJmapServers">
|
||||
<parentCategory ref="Bulwark_Desktop_connection"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -55,25 +49,12 @@
|
||||
<list id="AllowedJmapServers_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AllowedJmapServers_Recommended" class="Both" displayName="$(string.AllowedJmapServers)" explainText="$(string.AllowedJmapServers_Explain)" presentation="$(presentation.AllowedJmapServers_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended\AllowedJmapServers">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<list id="AllowedJmapServers_Recommended_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AllowInsecureInstance" class="Both" displayName="$(string.AllowInsecureInstance)" explainText="$(string.AllowInsecureInstance_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="AllowInsecureInstance">
|
||||
<parentCategory ref="Bulwark_Desktop_connection"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="AllowInsecureInstance_Recommended" class="Both" displayName="$(string.AllowInsecureInstance)" explainText="$(string.AllowInsecureInstance_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AllowInsecureInstance">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="UpdateChannel" class="Both" displayName="$(string.UpdateChannel)" explainText="$(string.UpdateChannel_Explain)" presentation="$(presentation.UpdateChannel)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_updates"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -117,17 +98,6 @@
|
||||
</enum>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="PluginsMode_Recommended" class="Both" displayName="$(string.PluginsMode)" explainText="$(string.PluginsMode_Explain)" presentation="$(presentation.PluginsMode_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<enum id="PluginsMode_Recommended_Value" valueName="PluginsMode" required="true">
|
||||
<item displayName="$(string.PluginsMode_allowed)"><value><string>allowed</string></value></item>
|
||||
<item displayName="$(string.PluginsMode_allowlist)"><value><string>allowlist</string></value></item>
|
||||
<item displayName="$(string.PluginsMode_off)"><value><string>off</string></value></item>
|
||||
</enum>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="PluginAllowlist" class="Both" displayName="$(string.PluginAllowlist)" explainText="$(string.PluginAllowlist_Explain)" presentation="$(presentation.PluginAllowlist)" key="Software\Policies\Bulwark\Desktop\PluginAllowlist">
|
||||
<parentCategory ref="Bulwark_Desktop_plugins"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -135,13 +105,6 @@
|
||||
<list id="PluginAllowlist_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="PluginAllowlist_Recommended" class="Both" displayName="$(string.PluginAllowlist)" explainText="$(string.PluginAllowlist_Explain)" presentation="$(presentation.PluginAllowlist_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended\PluginAllowlist">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<list id="PluginAllowlist_Recommended_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="OfflineEnabled" class="Both" displayName="$(string.OfflineEnabled)" explainText="$(string.OfflineEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="OfflineEnabled">
|
||||
<parentCategory ref="Bulwark_Desktop_security"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -225,13 +188,6 @@
|
||||
<text id="AuthServerAllowlist_Value" valueName="AuthServerAllowlist" required="true" maxLength="2048"/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AuthServerAllowlist_Recommended" class="Both" displayName="$(string.AuthServerAllowlist)" explainText="$(string.AuthServerAllowlist_Explain)" presentation="$(presentation.AuthServerAllowlist_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<text id="AuthServerAllowlist_Recommended_Value" valueName="AuthServerAllowlist" required="true" maxLength="2048"/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AuthNegotiateDelegateAllowlist" class="Both" displayName="$(string.AuthNegotiateDelegateAllowlist)" explainText="$(string.AuthNegotiateDelegateAllowlist_Explain)" presentation="$(presentation.AuthNegotiateDelegateAllowlist)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_signIn"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -239,13 +195,6 @@
|
||||
<text id="AuthNegotiateDelegateAllowlist_Value" valueName="AuthNegotiateDelegateAllowlist" required="true" maxLength="2048"/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AuthNegotiateDelegateAllowlist_Recommended" class="Both" displayName="$(string.AuthNegotiateDelegateAllowlist)" explainText="$(string.AuthNegotiateDelegateAllowlist_Explain)" presentation="$(presentation.AuthNegotiateDelegateAllowlist_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<text id="AuthNegotiateDelegateAllowlist_Recommended_Value" valueName="AuthNegotiateDelegateAllowlist" required="true" maxLength="2048"/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AutoSelectCertificateForUrls" class="Both" displayName="$(string.AutoSelectCertificateForUrls)" explainText="$(string.AutoSelectCertificateForUrls_Explain)" presentation="$(presentation.AutoSelectCertificateForUrls)" key="Software\Policies\Bulwark\Desktop\AutoSelectCertificateForUrls">
|
||||
<parentCategory ref="Bulwark_Desktop_signIn"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
@@ -253,11 +202,11 @@
|
||||
<list id="AutoSelectCertificateForUrls_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="AutoSelectCertificateForUrls_Recommended" class="Both" displayName="$(string.AutoSelectCertificateForUrls)" explainText="$(string.AutoSelectCertificateForUrls_Explain)" presentation="$(presentation.AutoSelectCertificateForUrls_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended\AutoSelectCertificateForUrls">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<policy name="KeystoreAllowedOrigins" class="Both" displayName="$(string.KeystoreAllowedOrigins)" explainText="$(string.KeystoreAllowedOrigins_Explain)" presentation="$(presentation.KeystoreAllowedOrigins)" key="Software\Policies\Bulwark\Desktop\KeystoreAllowedOrigins">
|
||||
<parentCategory ref="Bulwark_Desktop_signIn"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<elements>
|
||||
<list id="AutoSelectCertificateForUrls_Recommended_Value" valuePrefix=""/>
|
||||
<list id="KeystoreAllowedOrigins_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="WipeOfflineData" class="Both" displayName="$(string.WipeOfflineData)" explainText="$(string.WipeOfflineData_Explain)" presentation="$(presentation.WipeOfflineData)" key="Software\Policies\Bulwark\Desktop">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<string id="Category_Bulwark">Bulwark</string>
|
||||
<string id="Category_Desktop">Bulwark Desktop</string>
|
||||
<string id="Category_Recommended">Bulwark Desktop: Standardwerte, die Benutzer ändern können</string>
|
||||
<string id="SUPPORTED_Bulwark_Desktop">Bulwark Desktop 1.0 und höher</string>
|
||||
<string id="SUPPORTED_Bulwark_Desktop">Alle Versionen von Bulwark Desktop</string>
|
||||
<string id="Category_connection">Verbindung</string>
|
||||
<string id="Category_updates">Updates</string>
|
||||
<string id="Category_plugins">Plugins</string>
|
||||
@@ -20,7 +20,7 @@
|
||||
<string id="AllowStandalone">Direkte Verbindungen zu Mailservern zulassen</string>
|
||||
<string id="AllowStandalone_Explain">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.</string>
|
||||
<string id="AllowedJmapServers">Zugelassene Mailserver</string>
|
||||
<string id="AllowedJmapServers_Explain">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.</string>
|
||||
<string id="AllowedJmapServers_Explain">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.</string>
|
||||
<string id="AllowInsecureInstance">Bulwark-Server über unverschlüsseltes http zulassen</string>
|
||||
<string id="AllowInsecureInstance_Explain">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.</string>
|
||||
<string id="UpdateChannel">Update-Kanal</string>
|
||||
@@ -30,7 +30,7 @@
|
||||
<string id="AutoUpdate">Automatisch aktualisieren</string>
|
||||
<string id="AutoUpdate_Explain">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.</string>
|
||||
<string id="PluginsMode">Welche Plugins laufen dürfen</string>
|
||||
<string id="PluginsMode_Explain">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.</string>
|
||||
<string id="PluginsMode_Explain">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.</string>
|
||||
<string id="PluginsMode_allowed">Alle Plugins sind erlaubt</string>
|
||||
<string id="PluginsMode_allowlist">Nur Plugins auf der Liste</string>
|
||||
<string id="PluginsMode_off">Keine Plugins</string>
|
||||
@@ -54,30 +54,27 @@
|
||||
<string id="AuthNegotiateDelegateAllowlist_Explain">Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.</string>
|
||||
<string id="AutoSelectCertificateForUrls">Clientzertifikate automatisch wählen</string>
|
||||
<string id="AutoSelectCertificateForUrls_Explain">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.</string>
|
||||
<string id="KeystoreAllowedOrigins">Websites, die den Zertifikatspeicher des Systems verwenden dürfen</string>
|
||||
<string id="KeystoreAllowedOrigins_Explain">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.</string>
|
||||
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
|
||||
<string id="WipeOfflineData_Explain">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.</string>
|
||||
<string id="WipeOfflineData_Explain">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.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
|
||||
<presentation id="InstanceUrl_Recommended"><textBox refId="InstanceUrl_Recommended_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
|
||||
<presentation id="AllowedJmapServers"><listBox refId="AllowedJmapServers_Value">Zugelassene Mailserver</listBox></presentation>
|
||||
<presentation id="AllowedJmapServers_Recommended"><listBox refId="AllowedJmapServers_Recommended_Value">Zugelassene Mailserver</listBox></presentation>
|
||||
<presentation id="UpdateChannel"><dropdownList refId="UpdateChannel_Value" noSort="true" defaultItem="0">Update-Kanal</dropdownList></presentation>
|
||||
<presentation id="UpdateChannel_Recommended"><dropdownList refId="UpdateChannel_Recommended_Value" noSort="true" defaultItem="0">Update-Kanal</dropdownList></presentation>
|
||||
<presentation id="PluginsMode"><dropdownList refId="PluginsMode_Value" noSort="true" defaultItem="0">Welche Plugins laufen dürfen</dropdownList></presentation>
|
||||
<presentation id="PluginsMode_Recommended"><dropdownList refId="PluginsMode_Recommended_Value" noSort="true" defaultItem="0">Welche Plugins laufen dürfen</dropdownList></presentation>
|
||||
<presentation id="PluginAllowlist"><listBox refId="PluginAllowlist_Value">Plugin-IDs</listBox></presentation>
|
||||
<presentation id="PluginAllowlist_Recommended"><listBox refId="PluginAllowlist_Recommended_Value">Plugin-IDs</listBox></presentation>
|
||||
<presentation id="AppLockTimeoutMinutes"><decimalTextBox refId="AppLockTimeoutMinutes_Value" defaultValue="10">Minuten</decimalTextBox></presentation>
|
||||
<presentation id="AppLockTimeoutMinutes_Recommended"><decimalTextBox refId="AppLockTimeoutMinutes_Recommended_Value" defaultValue="10">Minuten</decimalTextBox></presentation>
|
||||
<presentation id="QuickComposeShortcut"><textBox refId="QuickComposeShortcut_Value"><label>Tastenkombination</label></textBox></presentation>
|
||||
<presentation id="QuickComposeShortcut_Recommended"><textBox refId="QuickComposeShortcut_Recommended_Value"><label>Tastenkombination</label></textBox></presentation>
|
||||
<presentation id="AuthServerAllowlist"><textBox refId="AuthServerAllowlist_Value"><label>Server</label></textBox></presentation>
|
||||
<presentation id="AuthServerAllowlist_Recommended"><textBox refId="AuthServerAllowlist_Recommended_Value"><label>Server</label></textBox></presentation>
|
||||
<presentation id="AuthNegotiateDelegateAllowlist"><textBox refId="AuthNegotiateDelegateAllowlist_Value"><label>Server</label></textBox></presentation>
|
||||
<presentation id="AuthNegotiateDelegateAllowlist_Recommended"><textBox refId="AuthNegotiateDelegateAllowlist_Recommended_Value"><label>Server</label></textBox></presentation>
|
||||
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Regeln</listBox></presentation>
|
||||
<presentation id="AutoSelectCertificateForUrls_Recommended"><listBox refId="AutoSelectCertificateForUrls_Recommended_Value">Regeln</listBox></presentation>
|
||||
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Adressen</listBox></presentation>
|
||||
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Löschcode</label></textBox></presentation>
|
||||
</presentationTable>
|
||||
</resources>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<string id="Category_Bulwark">Bulwark</string>
|
||||
<string id="Category_Desktop">Bulwark Desktop</string>
|
||||
<string id="Category_Recommended">Bulwark Desktop: defaults users can change</string>
|
||||
<string id="SUPPORTED_Bulwark_Desktop">Bulwark Desktop 1.0 and later</string>
|
||||
<string id="SUPPORTED_Bulwark_Desktop">All versions of Bulwark Desktop</string>
|
||||
<string id="Category_connection">Connection</string>
|
||||
<string id="Category_updates">Updates</string>
|
||||
<string id="Category_plugins">Plugins</string>
|
||||
@@ -20,7 +20,7 @@
|
||||
<string id="AllowStandalone">Allow direct connections to mail servers</string>
|
||||
<string id="AllowStandalone_Explain">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.</string>
|
||||
<string id="AllowedJmapServers">Allowed mail servers</string>
|
||||
<string id="AllowedJmapServers_Explain">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.</string>
|
||||
<string id="AllowedJmapServers_Explain">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.</string>
|
||||
<string id="AllowInsecureInstance">Allow a Bulwark server on plain http</string>
|
||||
<string id="AllowInsecureInstance_Explain">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.</string>
|
||||
<string id="UpdateChannel">Update channel</string>
|
||||
@@ -30,7 +30,7 @@
|
||||
<string id="AutoUpdate">Update automatically</string>
|
||||
<string id="AutoUpdate_Explain">When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates.</string>
|
||||
<string id="PluginsMode">Which plugins may run</string>
|
||||
<string id="PluginsMode_Explain">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.</string>
|
||||
<string id="PluginsMode_Explain">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.</string>
|
||||
<string id="PluginsMode_allowed">All plugins are allowed</string>
|
||||
<string id="PluginsMode_allowlist">Only plugins on the list</string>
|
||||
<string id="PluginsMode_off">No plugins</string>
|
||||
@@ -54,30 +54,27 @@
|
||||
<string id="AuthNegotiateDelegateAllowlist_Explain">The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.</string>
|
||||
<string id="AutoSelectCertificateForUrls">Choose client certificates automatically</string>
|
||||
<string id="AutoSelectCertificateForUrls_Explain">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.</string>
|
||||
<string id="KeystoreAllowedOrigins">Sites that may use the system certificate store</string>
|
||||
<string id="KeystoreAllowedOrigins_Explain">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.</string>
|
||||
<string id="WipeOfflineData">Delete mail kept on the device</string>
|
||||
<string id="WipeOfflineData_Explain">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.</string>
|
||||
<string id="WipeOfflineData_Explain">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.</string>
|
||||
</stringTable>
|
||||
<presentationTable>
|
||||
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Address of the Bulwark server</label></textBox></presentation>
|
||||
<presentation id="InstanceUrl_Recommended"><textBox refId="InstanceUrl_Recommended_Value"><label>Address of the Bulwark server</label></textBox></presentation>
|
||||
<presentation id="AllowedJmapServers"><listBox refId="AllowedJmapServers_Value">Allowed mail servers</listBox></presentation>
|
||||
<presentation id="AllowedJmapServers_Recommended"><listBox refId="AllowedJmapServers_Recommended_Value">Allowed mail servers</listBox></presentation>
|
||||
<presentation id="UpdateChannel"><dropdownList refId="UpdateChannel_Value" noSort="true" defaultItem="0">Update channel</dropdownList></presentation>
|
||||
<presentation id="UpdateChannel_Recommended"><dropdownList refId="UpdateChannel_Recommended_Value" noSort="true" defaultItem="0">Update channel</dropdownList></presentation>
|
||||
<presentation id="PluginsMode"><dropdownList refId="PluginsMode_Value" noSort="true" defaultItem="0">Which plugins may run</dropdownList></presentation>
|
||||
<presentation id="PluginsMode_Recommended"><dropdownList refId="PluginsMode_Recommended_Value" noSort="true" defaultItem="0">Which plugins may run</dropdownList></presentation>
|
||||
<presentation id="PluginAllowlist"><listBox refId="PluginAllowlist_Value">Plugin ids</listBox></presentation>
|
||||
<presentation id="PluginAllowlist_Recommended"><listBox refId="PluginAllowlist_Recommended_Value">Plugin ids</listBox></presentation>
|
||||
<presentation id="AppLockTimeoutMinutes"><decimalTextBox refId="AppLockTimeoutMinutes_Value" defaultValue="10">Minutes</decimalTextBox></presentation>
|
||||
<presentation id="AppLockTimeoutMinutes_Recommended"><decimalTextBox refId="AppLockTimeoutMinutes_Recommended_Value" defaultValue="10">Minutes</decimalTextBox></presentation>
|
||||
<presentation id="QuickComposeShortcut"><textBox refId="QuickComposeShortcut_Value"><label>Shortcut</label></textBox></presentation>
|
||||
<presentation id="QuickComposeShortcut_Recommended"><textBox refId="QuickComposeShortcut_Recommended_Value"><label>Shortcut</label></textBox></presentation>
|
||||
<presentation id="AuthServerAllowlist"><textBox refId="AuthServerAllowlist_Value"><label>Servers</label></textBox></presentation>
|
||||
<presentation id="AuthServerAllowlist_Recommended"><textBox refId="AuthServerAllowlist_Recommended_Value"><label>Servers</label></textBox></presentation>
|
||||
<presentation id="AuthNegotiateDelegateAllowlist"><textBox refId="AuthNegotiateDelegateAllowlist_Value"><label>Servers</label></textBox></presentation>
|
||||
<presentation id="AuthNegotiateDelegateAllowlist_Recommended"><textBox refId="AuthNegotiateDelegateAllowlist_Recommended_Value"><label>Servers</label></textBox></presentation>
|
||||
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Rules</listBox></presentation>
|
||||
<presentation id="AutoSelectCertificateForUrls_Recommended"><listBox refId="AutoSelectCertificateForUrls_Recommended_Value">Rules</listBox></presentation>
|
||||
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Addresses</listBox></presentation>
|
||||
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Wipe code</label></textBox></presentation>
|
||||
</presentationTable>
|
||||
</resources>
|
||||
|
||||
@@ -57,6 +57,10 @@
|
||||
<array>
|
||||
<string>{"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}</string>
|
||||
</array>
|
||||
<key>KeystoreAllowedOrigins</key>
|
||||
<array>
|
||||
<string>https://mail.example.com</string>
|
||||
</array>
|
||||
<key>Recommended</key>
|
||||
<dict>
|
||||
<key>UpdateChannel</key>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
"AutoSelectCertificateForUrls": [
|
||||
"{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}"
|
||||
],
|
||||
"KeystoreAllowedOrigins": [
|
||||
"https://mail.example.com"
|
||||
],
|
||||
"Recommended": {
|
||||
"UpdateChannel": "stable"
|
||||
}
|
||||
|
||||
@@ -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' })] });
|
||||
|
||||
@@ -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<string, unknown>) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine });
|
||||
const bridgeWith = (current: ReturnType<typeof snapshot>) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {}, onWipeOfflineData: () => () => {} } });
|
||||
const bridgeWith = (current: ReturnType<typeof snapshot>) => ({ 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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<ServerPolicy> | 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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AllowStandalone: {
|
||||
category: 'connection',
|
||||
forcedOnly: true,
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
onInvalid: false,
|
||||
@@ -159,6 +165,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AllowedJmapServers: {
|
||||
category: 'connection',
|
||||
forcedOnly: true,
|
||||
type: 'list',
|
||||
item: 'server',
|
||||
default: null,
|
||||
@@ -166,12 +173,13 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
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<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
PluginsMode: {
|
||||
category: 'plugins',
|
||||
forcedOnly: true,
|
||||
type: 'enum',
|
||||
default: 'allowed',
|
||||
onInvalid: 'off',
|
||||
@@ -223,12 +232,13 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
],
|
||||
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<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AuthServerAllowlist: {
|
||||
category: 'signIn',
|
||||
forcedOnly: true,
|
||||
type: 'string',
|
||||
format: 'hostList',
|
||||
default: null,
|
||||
@@ -332,6 +343,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AuthNegotiateDelegateAllowlist: {
|
||||
category: 'signIn',
|
||||
forcedOnly: true,
|
||||
type: 'string',
|
||||
format: 'hostList',
|
||||
default: null,
|
||||
@@ -346,6 +358,7 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
},
|
||||
AutoSelectCertificateForUrls: {
|
||||
category: 'signIn',
|
||||
forcedOnly: true,
|
||||
type: 'list',
|
||||
item: 'certificate',
|
||||
default: [],
|
||||
@@ -357,6 +370,20 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
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<PolicyKey, PolicyDefinition> = {
|
||||
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<s
|
||||
return fail('scheme');
|
||||
}
|
||||
if (!url.hostname || url.username || url.password || url.search || url.hash) return fail('invalid_url');
|
||||
// After the parser's work a real name is plain letters, digits, dots and hyphens (an
|
||||
// international one becomes xn--), or an IP literal. Anything else is not an address.
|
||||
if (!/^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])$/.test(url.hostname)) return fail('invalid_url');
|
||||
return { ok: true, value: `${url.origin}${url.pathname.replace(/\/+$/, '')}/` };
|
||||
}
|
||||
|
||||
@@ -488,6 +518,19 @@ export function jmapServerAllowed(patterns: readonly string[] | null | undefined
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One entry of KeystoreAllowedOrigins: an https origin. */
|
||||
export function parseHttpsOrigin(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string' || raw.length > 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<string> {
|
||||
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<T>(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<PolicyKey, PolicyConsumer> = {
|
||||
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;
|
||||
|
||||
@@ -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<string, string> {
|
||||
`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'),
|
||||
};
|
||||
}
|
||||
|
||||
+16
-9
@@ -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<unknown>): () => 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<unknown>) => () => void;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
|
||||
@@ -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<CustomUpstream | null> {
|
||||
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<CustomUpstream | null> {
|
||||
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
|
||||
await configManager.ensureLoaded();
|
||||
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get<string>('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 {
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
export async function runSync(account: string): Promise<void> {
|
||||
// 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;
|
||||
|
||||
@@ -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<void> {
|
||||
async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
+66
-5
@@ -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<AuthState>()(
|
||||
// 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<AuthState>()(
|
||||
|
||||
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<AuthState>()(
|
||||
|
||||
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<AuthState>()(
|
||||
|
||||
// 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<string>();
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
||||
@@ -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<PluginStoreState>()(
|
||||
|
||||
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<PluginStoreState>()(
|
||||
}
|
||||
|
||||
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<PluginStoreState>()(
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user