From bdac27367cdffd5609c35f4782723f078fbcc618 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:20:02 +0100 Subject: [PATCH] fix: keep the organisation's locks when the policy can't be fetched --- lib/outgoing/send-pipeline.ts | 4 + lib/sign-out-cleanup.ts | 3 + .../policy-store-offline-fallback.test.ts | 93 +++++++++++++++++++ stores/policy-store.ts | 83 ++++++++++++++++- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 stores/__tests__/policy-store-offline-fallback.test.ts diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index c49f3e0b4..5ef1ecc9e 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -17,6 +17,7 @@ */ import { debug } from '@/lib/debug'; +import { whenPolicySettled } from '@/stores/policy-store'; // 'plugin-forward': a plugin forwarding a message (api.email.forward). export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward'; @@ -130,6 +131,9 @@ export function finalizeOutgoing( * one must not make sending impossible. */ export async function runPreSendChecks(context: OutgoingContext): Promise { + // The checks and what the composer adds after them (labels, signatures) + // follow the organisation policy: wait for its first fetch. + await whenPolicySettled(); for (const entry of [...checks]) { try { if (!(await entry.run(context))) { diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 2162353b8..d89b38355 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -26,6 +26,9 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'files-path-stack', // Names of the people the user gave access to (Settings > Sharing). 'bulwark-delegate-names', + // The last organisation policy (stores/policy-store.ts POLICY_CACHE_KEY): + // its scopes, labels and notices are the signed-in user's. + 'bulwark-policy-last', ]; const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; diff --git a/stores/__tests__/policy-store-offline-fallback.test.ts b/stores/__tests__/policy-store-offline-fallback.test.ts new file mode 100644 index 000000000..1eb001a5d --- /dev/null +++ b/stores/__tests__/policy-store-offline-fallback.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// A failed policy fetch must not lift the organisation's locks: a refetch +// keeps what the server sent, and a fresh start without the server uses the +// last policy this browser saw instead of the defaults. + +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock })); + +import { POLICY_CACHE_KEY, usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +const LOCKED = { + ...DEFAULT_POLICY, + restrictions: { externalContentPolicy: { locked: true, value: 'block' } }, +}; + +const ok = (policy: object) => new Response(JSON.stringify(policy)); +const locked = () => usePolicyStore.getState().isSettingLocked('externalContentPolicy'); + +beforeEach(() => { + fetchMock.mockReset(); + localStorage.clear(); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, fetching: false, source: 'none', partial: false }); +}); + +describe('policy fetch failures', () => { + it('keeps the server policy when a later fetch fails', async () => { + fetchMock.mockResolvedValueOnce(ok(LOCKED)); + await usePolicyStore.getState().fetchPolicy(); + expect(locked()).toBe(true); + + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await usePolicyStore.getState().fetchPolicy(); + expect(locked()).toBe(true); + + fetchMock.mockResolvedValueOnce(new Response('oops', { status: 502 })); + await usePolicyStore.getState().fetchPolicy(); + expect(locked()).toBe(true); + expect(usePolicyStore.getState().source).toBe('server'); + }); + + it('starts from the last policy this browser saw when the server is out of reach', async () => { + fetchMock.mockResolvedValueOnce(ok(LOCKED)); + await usePolicyStore.getState().fetchPolicy(); + expect(localStorage.getItem(POLICY_CACHE_KEY)).toContain('externalContentPolicy'); + + // A new page load: nothing in memory, no network. + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, source: 'none' }); + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().source).toBe('cache'); + expect(locked()).toBe(true); + }); + + it('uses the defaults only when no policy was ever fetched', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().source).toBe('fallback'); + expect(usePolicyStore.getState().loaded).toBe(true); + expect(locked()).toBe(false); + }); + + it("doesn't keep the public part a signed-out page gets", async () => { + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(DEFAULT_POLICY), { headers: { 'X-Bulwark-Policy-Scope': 'public' } })); + await usePolicyStore.getState().fetchPolicy(); + expect(localStorage.getItem(POLICY_CACHE_KEY)).toBeNull(); + }); +}); + +describe('whenPolicySettled', () => { + it('waits for the first fetch, then resolves', async () => { + let release!: (r: Response) => void; + fetchMock.mockReturnValueOnce(new Promise((resolve) => { release = resolve; })); + const fetching = usePolicyStore.getState().fetchPolicy(); + let settled = false; + const wait = whenPolicySettled(5000).then(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + release(ok(LOCKED)); + await fetching; + await wait; + expect(settled).toBe(true); + }); + + it('gives up after its timeout', async () => { + fetchMock.mockReturnValueOnce(new Promise(() => {})); + void usePolicyStore.getState().fetchPolicy(); + const started = Date.now(); + await whenPolicySettled(50); + expect(Date.now() - started).toBeGreaterThanOrEqual(40); + }); +}); diff --git a/stores/policy-store.ts b/stores/policy-store.ts index cabb84065..864436af4 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -35,6 +35,14 @@ interface PolicyState { * things the policy may lock fail closed (hooks/use-third-party-mode.ts). */ fetching: boolean; + /** + * Where `rawPolicy` came from: the server, the copy of the last fetched + * policy kept in this browser (the fetch failed), or the built-in + * defaults (nothing was ever fetched). A loaded server policy is never + * replaced by the copy or the defaults, so a network blip can't lift the + * organisation's locks. + */ + source: 'none' | 'server' | 'cache' | 'fallback'; /** * The server answered with the public subset (not signed in yet); the * full policy is fetched again once a session exists. @@ -78,6 +86,55 @@ interface PolicyState { let policyRequest = 0; +/** + * The last full policy the server sent, kept so a failed fetch (network + * down, a refetch that errors) doesn't fall back to the defaults and lift + * every lock. Removed at full sign-out (lib/sign-out-cleanup.ts). + */ +export const POLICY_CACHE_KEY = 'bulwark-policy-last'; + +function readCachedPolicy(): Record | null { + try { + const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(POLICY_CACHE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function writeCachedPolicy(data: unknown): void { + try { + if (typeof localStorage === 'undefined') return; + if (data && typeof data === 'object') localStorage.setItem(POLICY_CACHE_KEY, JSON.stringify(data)); + else localStorage.removeItem(POLICY_CACHE_KEY); + } catch { + // Storage full or blocked: the in-memory policy still applies. + } +} + +/** + * Resolves once the first policy fetch has settled (or after `timeoutMs`). + * Sending waits for it, so a required label or a send check the policy + * turns on can't be skipped by sending in the first moment after load. + */ +export async function whenPolicySettled(timeoutMs = 5000): Promise { + const settled = () => { + const { fetching, loaded } = usePolicyStore.getState(); + return loaded || !fetching; + }; + if (settled()) return; + await new Promise((resolve) => { + const timer = setTimeout(() => { unsubscribe(); resolve(); }, timeoutMs); + const unsubscribe = usePolicyStore.subscribe(() => { + if (!settled()) return; + clearTimeout(timer); + unsubscribe(); + resolve(); + }); + }); +} + const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system']; const sameSubject = (a: PolicySubject | null, b: PolicySubject | null) => @@ -96,6 +153,7 @@ export const usePolicyStore = create()((set, get) => ({ scopeDomains: null, loaded: false, fetching: false, + source: 'none', partial: false, demoFeatureOverrides: {}, @@ -110,6 +168,20 @@ export const usePolicyStore = create()((set, get) => ({ // for the admin server; missing means defaults, and the gates that need // the server stay pinned off either way (lib/lite-config.ts). const fallback = IS_LITE ? { policy: applyLitePolicy({}) } : {}; + // The fetch failed: keep a policy the server already gave, else the last + // one this browser saw, else the defaults. + const settleWithoutServer = () => { + if (get().source === 'server') { + set({ loaded: true }); + return; + } + const cached = readCachedPolicy(); + if (cached) { + set({ ...resolved(IS_LITE ? applyLitePolicy(cached) : (cached as unknown as SettingsPolicy), get().subject), loaded: true, source: 'cache' }); + } else { + set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'fallback' }); + } + }; try { // Stalwart serves bundle files immutably: a build-id URL instead of no-store. const res = IS_LITE_STALWART @@ -122,12 +194,17 @@ export const usePolicyStore = create()((set, get) => ({ const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public'; const domainsHeader = res.headers?.get?.(POLICY_DOMAINS_HEADER); const scopeDomains = typeof domainsHeader === 'string' ? domainsHeader.split(',').filter(Boolean) : null; - set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial, scopeDomains }); + set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, source: 'server', partial, scopeDomains }); + if (!partial) writeCachedPolicy(data); + } else if (IS_LITE && res.status === 404) { + // No policy.json: the deployment set nothing, which is an answer. + set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'server' }); + writeCachedPolicy(null); } else { - set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true }); + settleWithoutServer(); } } catch { - if (request === policyRequest) set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true }); + if (request === policyRequest) settleWithoutServer(); } finally { if (request === policyRequest) set({ fetching: false }); }