Files
webmail/lib/__tests__/plugin-device-policy.test.ts
T
Linus Rath 2edd6b5ea7 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
2026-11-03 12:06:21 +01:00

200 lines
9.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer } from '@/lib/desktop/policy-schema';
/**
* PluginsMode on the client: the only gate there is on an instance. And the trap: a
* plugin missing from /api/plugins counts as removed by the admin, which deletes its
* bundle, data and connected accounts. Tightening a policy must not do that.
*/
vi.mock('@/lib/plugin-storage', () => ({
pluginStorage: {
saveCode: vi.fn().mockResolvedValue(undefined),
getCode: vi.fn().mockResolvedValue('code'),
codeIds: vi.fn().mockResolvedValue(['kept', 'refused-one']),
deleteCode: vi.fn().mockResolvedValue(undefined),
saveThemeCSS: vi.fn().mockResolvedValue(undefined),
getThemeCSS: vi.fn().mockResolvedValue(null),
deleteThemeCSS: vi.fn().mockResolvedValue(undefined),
savePreview: vi.fn().mockResolvedValue(undefined),
getPreview: vi.fn().mockResolvedValue(null),
deletePreview: vi.fn().mockResolvedValue(undefined),
},
fileStorage: { clearFiles: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock('@/lib/plugin-validator', () => ({ extractPlugin: vi.fn() }));
vi.mock('@/lib/plugin-loader', () => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
deactivatePlugin: vi.fn(),
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
setSandboxLocale: vi.fn(),
forgetSlotOffers: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({ removeAllPluginHooks: vi.fn() }));
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), downloadManagedBundle: vi.fn() }));
// In the app the bridge is there before any page code runs, and the store subscribes
// when it's first imported. So it's here before the imports below.
const harness = vi.hoisted(() => {
const state = {
current: null as unknown,
listeners: [] as ((snapshot: unknown) => void)[],
};
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
policy: {
get: () => state.current,
onChange: (listener: (snapshot: unknown) => void) => {
state.listeners.push(listener);
return () => {};
},
},
};
return state;
});
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: (...args: unknown[]) => mocks.apiFetch(...args) }));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
forEachInstance: () => {},
snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })),
}));
vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args) }));
import { register as registerActive, deregister as deregisterActive } from '@/lib/plugin-sandbox/registry';
import { usePluginStore } from '@/stores/plugin-store';
import { pluginStorage } from '@/lib/plugin-storage';
import { deactivatePlugin, forgetSlotOffers, loadPlugin } from '@/lib/plugin-loader';
import { extractPlugin } from '@/lib/plugin-validator';
function snapshotOf(forcedMachine: PolicyLayer) {
return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine });
}
function setPolicy(forced: PolicyLayer) {
harness.current = snapshotOf(forced);
for (const listener of harness.listeners) listener(harness.current);
}
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'refused-one', name: 'R', version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js',
permissions: [], enabled: true, status: 'running', managed: true, settings: { mine: 'kept' }, ...overrides,
};
}
const listed = (id: string, extra: object = {}) => ({
id, name: id, version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js', permissions: [], forceEnabled: false, bundleHash: 'hash', ...extra,
});
function serve(plugins: unknown[]) {
mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ plugins, themes: [] })));
}
beforeEach(() => {
vi.clearAllMocks();
usePluginStore.setState({ plugins: [], initialized: false });
setPolicy({});
localStorage.clear();
usePluginStore.setState({ plugins: [], initialized: false });
});
describe('tightening the policy destroys nothing', () => {
function expectNothingDeleted(id: string) {
expect(usePluginStore.getState().plugins.find((p) => p.id === id)).toMatchObject({ id, settings: { mine: 'kept' } });
expect(localStorage.getItem(`plugin:${id}:@acct:notes`)).toBe('"secret"');
expect(pluginStorage.deleteCode).not.toHaveBeenCalledWith(id);
expect(pluginStorage.deletePreview).not.toHaveBeenCalledWith(id);
expect(forgetSlotOffers).not.toHaveBeenCalledWith(id);
}
beforeEach(() => {
localStorage.setItem('server-managed-plugin-ids', JSON.stringify(['refused-one']));
localStorage.setItem('plugin:refused-one:@acct:notes', '"secret"');
usePluginStore.setState({ plugins: [plugin()] });
});
it('the embedded server flags it: record, data and bundle stay, and nothing is downloaded', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('refused-one', { refused: true, version: '2.0.0', bundleHash: 'changed' })]);
await usePluginStore.getState().initializePlugins();
expectNothingDeleted('refused-one');
expect(usePluginStore.getState().plugins[0].version).toBe('1.0.0');
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('an instance knows nothing of the policy and lists it plainly: the client refuses by itself', async () => {
setPolicy({ PluginsMode: 'off' });
serve([listed('refused-one', { version: '2.0.0', bundleHash: 'changed' })]);
await usePluginStore.getState().initializePlugins();
expectNothingDeleted('refused-one');
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('a plugin the admin really removed still goes, with the policy on', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([]);
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins.some((p) => p.id === 'refused-one')).toBe(false);
expect(localStorage.getItem('plugin:refused-one:@acct:notes')).toBeNull();
});
it('a change while running stops it and keeps everything, and lifting it starts it again', async () => {
registerActive({ plugin: plugin(), code: 'code', tier: 'untrusted', background: { destroy: vi.fn() }, slotOffers: [], hookDisposables: [] } as never);
setPolicy({ PluginsMode: 'off' });
expect(deactivatePlugin).toHaveBeenCalledWith('refused-one');
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: true, status: 'error', error: 'device-policy' });
expectNothingDeleted('refused-one');
deregisterActive('refused-one');
setPolicy({});
expect(loadPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: 'refused-one' }));
expect(usePluginStore.getState().plugins[0].error).toBeUndefined();
});
});
describe('the client gates', () => {
it('a new plugin the policy refuses isn\'t added or downloaded', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('refused-one')]);
mocks.downloadManagedBundle.mockResolvedValue('code');
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins).toEqual([]);
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('an allowed one is', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('kept')]);
mocks.downloadManagedBundle.mockResolvedValue('code');
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins.map((p) => p.id)).toEqual(['kept']);
});
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: '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' })] });
await usePluginStore.getState().enablePlugin('refused-one');
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' });
expect(loadPlugin).not.toHaveBeenCalled();
});
});