From 15cec58a465cce13f1fdfb65aa5c756e2afe5442 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:27:35 +0200 Subject: [PATCH] fix: serve plugin bundles only to signed-in users --- .../[id]/bundle/__tests__/route.test.ts | 36 +++++++++++++++++++ app/api/admin/plugins/[id]/bundle/route.ts | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts diff --git a/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts new file mode 100644 index 000000000..7c73cd2a3 --- /dev/null +++ b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let signedIn = false; +vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => signedIn) })); +vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: vi.fn(async () => null), readDevBundle: vi.fn() })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPlugin: vi.fn(async () => ({ id: 'demo', enabled: true, bundleHash: 'h' })), + getPluginBundle: vi.fn(async () => 'module.exports = {}'), + getPluginSignature: vi.fn(async () => 'sig'), +})); +vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: vi.fn(async () => 'sig') })); + +import { GET } from '../route'; + +function get() { + return GET(new NextRequest('http://localhost/api/admin/plugins/demo/bundle'), { params: Promise.resolve({ id: 'demo' }) }); +} + +beforeEach(() => { signedIn = false; }); + +describe('GET plugin bundle', () => { + it('does not hand plugin code to someone who is not signed in', async () => { + expect((await get()).status).toBe(401); + }); + + it('serves it to a signed-in user with its signature', async () => { + signedIn = true; + const res = await get(); + expect(res.status).toBe(200); + expect(res.headers.get('x-bundle-signature')).toBe('sig'); + }); +}); diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts index cb335a101..06b398ae5 100644 --- a/app/api/admin/plugins/[id]/bundle/route.ts +++ b/app/api/admin/plugins/[id]/bundle/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry'; import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev'; import { signBytes } from '@/lib/admin/plugin-signing'; +import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; async function safeSign(code: string): Promise { try { return await signBytes(code); } catch { return null; } @@ -10,13 +11,18 @@ async function safeSign(code: string): Promise { /** * GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle * - * Public endpoint so the client-side plugin loader can fetch bundles. + * Serves bundles to signed-in users so the client-side plugin loader can fetch them. * Only serves plugins that exist in the registry and are enabled. */ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { + // Bundles are delivered to signed-in users only, like the plugin list + // that names them (/api/plugins). + if (!(await canSeeInstanceDetails(request))) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } try { const { id } = await params;