mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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
200 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|