diff --git a/app/api/plugin-approval-status/__tests__/route.test.ts b/app/api/plugin-approval-status/__tests__/route.test.ts index 7d0ba7970..c7c16374c 100644 --- a/app/api/plugin-approval-status/__tests__/route.test.ts +++ b/app/api/plugin-approval-status/__tests__/route.test.ts @@ -11,6 +11,11 @@ vi.mock('@/lib/stalwart/auth-context', () => ({ })); vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null })); +let features: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features }) }, +})); + let currentUser = 'alice@example.org'; const entries: Array> = []; const bundles = new Map(); @@ -55,6 +60,7 @@ beforeEach(() => { entries.length = 0; bundles.clear(); currentUser = 'alice@example.org'; + features = { pluginsEnabled: true, pluginsUploadEnabled: true, requirePluginApproval: true }; }); describe('POST /api/plugin-approval-status', () => { @@ -87,6 +93,19 @@ describe('POST /api/plugin-approval-status', () => { expect(entries).toHaveLength(0); }); + it.each([ + ['plugins are off', { pluginsEnabled: false }], + ['user plugins are off', { pluginsUploadEnabled: false }], + ['approval is not required', { requirePluginApproval: false }], + ])('takes no package while %s', async (_label, off) => { + features = { ...features, ...off }; + const res = await submit(await zipOf(MANIFEST)); + + expect(res.status).toBe(403); + expect(entries).toHaveLength(0); + expect(bundles.size).toBe(0); + }); + it('limits how many requests one user can have open', async () => { for (let i = 0; i < 10; i++) { expect((await submit(await zipOf({ ...MANIFEST, id: `p-${i}x` }))).status).toBe(200); diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts index 8d8374651..91504efb5 100644 --- a/app/api/plugin-approval-status/route.ts +++ b/app/api/plugin-approval-status/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { configManager } from '@/lib/admin/config-manager'; import { cookies } from 'next/headers'; import { decryptSession } from '@/lib/auth/crypto'; import { sessionCookieName } from '@/lib/auth/session-cookie'; @@ -75,6 +76,15 @@ export async function POST(request: NextRequest) { const username = await resolveUsername(); if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 }); + // Only take packages while users may install their own plugins and + // those need review (GHSA-qc44-275p-gqw9): otherwise nobody asked for + // the queue, and every request still stores a ZIP on disk. + await configManager.ensureLoaded(); + const { features } = configManager.getPolicy(); + if (!features.pluginsEnabled || !features.pluginsUploadEnabled || !features.requirePluginApproval) { + return NextResponse.json({ error: 'Plugin approval requests are turned off' }, { status: 403 }); + } + // The request carries the package itself. The admin reviews the code // and manifest the server read from it, and the approval is pinned to // the hash the server computed, not to anything the client claims.