fix: update the plugin frame origins in the CSP right after an install, also on other instances #1066

This commit is contained in:
Linus Rath
2026-11-01 00:06:52 +01:00
parent bcb333d40c
commit 8b40cfca90
3 changed files with 198 additions and 12 deletions
@@ -0,0 +1,69 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
// The proxy builds frame-src from the plugins' frame origins and caches
// them for 5 s. It has its own copy of csp-frame-origins.ts, so clearing
// the cache from an install route used to clear only the route's copy: the
// proxy kept the old frame-src until its 5 s ran out. The cache is one per
// process now, and an install, update or removal clears it at once.
vi.mock('../third-party-mode', () => ({ isNoThirdPartyTrafficOn: vi.fn(async () => false) }));
vi.mock('../plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
const PLUGIN = {
id: 'video-embed', name: 'Video', version: '1.0.0', author: 'a', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, installedAt: 'x', updatedAt: 'x',
};
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'bw-csp-cache-'));
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
delete (globalThis as Record<symbol, unknown>)[Symbol.for('bulwark.admin.cspFrameOrigins')];
});
afterEach(async () => {
vi.unstubAllEnvs();
await rm(dir, { recursive: true, force: true });
});
/** A separate copy of the modules, as each bundle entry (proxy, route) has. */
async function moduleCopy() {
vi.resetModules();
return {
origins: await import('../csp-frame-origins'),
registry: await import('../plugin-registry'),
};
}
async function writeRegistry(frameOrigins: string[]) {
await mkdir(path.join(dir, 'plugins'), { recursive: true });
await writeFile(path.join(dir, 'plugins', 'registry.json'), JSON.stringify({ plugins: [{ ...PLUGIN, frameOrigins }] }));
}
describe('plugin frame origins cache', () => {
it('is cleared in the proxy copy when a route clears it', async () => {
const proxyCopy = await moduleCopy();
const routeCopy = await moduleCopy();
await writeRegistry(['https://video.example.com']);
expect(await proxyCopy.origins.getEnabledPluginFrameOrigins()).toEqual(['https://video.example.com']);
await writeRegistry(['https://other.example.com']);
routeCopy.origins.invalidateFrameOriginsCache();
expect(await proxyCopy.origins.getEnabledPluginFrameOrigins()).toEqual(['https://other.example.com']);
});
it('is cleared in the proxy copy when a plugin is switched off', async () => {
const proxyCopy = await moduleCopy();
const routeCopy = await moduleCopy();
await routeCopy.registry.savePlugin({ ...PLUGIN, frameOrigins: ['https://video.example.com'] }, 'code()');
expect(await proxyCopy.origins.getEnabledPluginFrameOrigins()).toEqual(['https://video.example.com']);
await routeCopy.registry.updatePluginMeta(PLUGIN.id, { enabled: false });
expect(await proxyCopy.origins.getEnabledPluginFrameOrigins()).toEqual([]);
});
});
@@ -0,0 +1,93 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
// Several replicas share one store (#1066): after a plugin with frame
// origins is installed through another replica, this replica's proxy must
// put them into frame-src on the next change poll, not only after its
// 5-second cache runs out.
vi.mock('@/lib/auth/instance-details', () => ({
canSeeInstanceDetails: async () => true,
signedInIdentity: async () => ({ admin: true }),
}));
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
vi.mock('@/lib/wopi/discovery', () => ({ getWopiEditorOrigins: vi.fn(async () => []) }));
vi.mock('next-intl/middleware', async () => {
const { NextResponse } = await import('next/server');
return { default: () => () => NextResponse.next() };
});
import { configManager } from '../config-manager';
import { adminConfigDocs } from '../admin-store';
import { pluginDocs } from '../extension-store';
import { setBootState, setStoreForTests } from '@/lib/shared-store';
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
import { INIT_EPOCH, markEpochsSeen, pollChanges, resetChangesForTests } from '@/lib/shared-store/changes';
import { separateFakeStore } from './shared-store-fake';
const PLUGIN = {
id: 'video-embed', name: 'Video', version: '1.0.0', author: 'a', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, installedAt: 'x', updatedAt: 'x',
};
let dir: string;
let store: ReturnType<typeof separateFakeStore>;
const registryWith = (frameOrigins: string[]) => JSON.stringify({ plugins: [{ ...PLUGIN, frameOrigins }] });
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'bw-csp-shared-'));
vi.stubEnv('ADMIN_CONFIG_DIR', path.join(dir, 'config'));
vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state'));
resetLocalStoreForTests();
resetChangesForTests();
delete (globalThis as Record<symbol, unknown>)[Symbol.for('bulwark.admin.cspFrameOrigins')];
store = separateFakeStore(path.join(dir, 'store'));
setStoreForTests(store);
await store.epochs.bump(INIT_EPOCH);
await adminConfigDocs().put('config.json', JSON.stringify({ setupComplete: true }));
await adminConfigDocs().put('policy.json', JSON.stringify({ features: { pluginsEnabled: true } }));
await pluginDocs().put('registry.json', registryWith(['https://first.example.com']));
await markEpochsSeen();
await configManager.load();
setBootState('ready');
});
afterEach(async () => {
setStoreForTests(null);
resetChangesForTests();
resetLocalStoreForTests();
vi.unstubAllEnvs();
await rm(dir, { recursive: true, force: true });
});
describe('plugin frame origins with a shared store', () => {
it('reach the proxy within one poll after an install on another replica', async () => {
// The proxy's own module copies, loaded now (as at process start).
vi.resetModules();
const { proxy } = await import('@/proxy');
const frameSrc = async () => {
const res = await proxy(new NextRequest('http://localhost:3000/'));
return /frame-src [^;]*/.exec(res.headers.get('content-security-policy') ?? '')?.[0] ?? '';
};
expect(await frameSrc()).toContain('https://first.example.com');
// Another replica installs: the store changes and the epoch moves.
await pluginDocs().put('registry.json', registryWith(['https://second.example.com']));
await store.epochs.bump('plugins');
await pollChanges();
const after = await frameSrc();
expect(after).toContain('https://second.example.com');
expect(after).not.toContain('https://first.example.com');
});
});
+36 -12
View File
@@ -12,6 +12,7 @@
import { getPluginRegistry } from './plugin-registry';
import { sanitizeFrameOrigins } from '../extension-origins';
import { isNoThirdPartyTrafficOn } from './third-party-mode';
import { onSharedChange } from '@/lib/shared-store/changes';
export {
isValidFrameOrigin,
@@ -25,10 +26,27 @@ export {
// registry JSON every time is fine but cheap to skip when nothing has
// changed. Five seconds is short enough to make plugin install/uninstall
// feel snappy without measurable overhead.
let cachedAt = 0;
let cachedOrigins: string[] = [];
//
// On globalThis: the proxy has its own copy of this module, and an install
// handled by a route must clear the proxy's cache too, not only the route's.
// Dropped at once when the plugins (or the config and policy that gate
// them) change, on this replica or another one (#1066).
interface FrameOriginsCache {
at: number;
origins: string[];
/** Bumped on every invalidation, so a read that started before can't store stale origins. */
generation: number;
}
const CACHE_KEY = Symbol.for('bulwark.admin.cspFrameOrigins');
const CACHE_TTL_MS = 5_000;
function cache(): FrameOriginsCache {
const g = globalThis as typeof globalThis & { [CACHE_KEY]?: FrameOriginsCache };
return (g[CACHE_KEY] ??= { at: 0, origins: [], generation: 0 });
}
for (const area of ['plugins', 'config', 'policy']) onSharedChange(area, () => invalidateFrameOriginsCache());
/**
* Returns the union of frame origins declared by every enabled plugin in
* the server-side registry, deduped and validated.
@@ -37,14 +55,20 @@ const CACHE_TTL_MS = 5_000;
* a broken registry only ever shrinks the CSP - never widens it.
*/
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const c = cache();
const now = Date.now();
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
if (now - c.at < CACHE_TTL_MS) return c.origins;
const generation = c.generation;
const remember = (origins: string[]) => {
if (c.generation !== generation) return;
c.origins = origins;
c.at = now;
};
try {
// No outside frames at all while the server stays off outside hosts (#903).
if (await isNoThirdPartyTrafficOn()) {
cachedOrigins = [];
cachedAt = now;
remember([]);
return [];
}
const registry = await getPluginRegistry();
@@ -64,18 +88,18 @@ export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
out.push(o);
}
}
cachedOrigins = out;
cachedAt = now;
remember(out);
return out;
} catch {
cachedOrigins = [];
cachedAt = now;
remember([]);
return [];
}
}
/** Force the next call to re-read the registry. Used by install/uninstall. */
/** Force the next call (in every module copy) to re-read the registry. Used by install/uninstall. */
export function invalidateFrameOriginsCache(): void {
cachedAt = 0;
cachedOrigins = [];
const c = cache();
c.at = 0;
c.origins = [];
c.generation++;
}