From 468c1aac0c100a05bb0fb9bb7d17c1f84d9253f8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:32:44 +0200 Subject: [PATCH] fix: refuse marketplace bundles whose id differs from the directory entry --- .../admin/marketplace/__tests__/route.test.ts | 87 +++++++++++++++++++ app/api/admin/marketplace/route.ts | 11 ++- 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/marketplace/__tests__/route.test.ts diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts new file mode 100644 index 000000000..069a7242b --- /dev/null +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import JSZip from 'jszip'; + +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(() => 'https://extensions.test'), + }, +})); + +const registry = new Map>(); +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + saveTheme: vi.fn(async () => {}), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getTheme: vi.fn(async () => null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + getThemeRegistry: vi.fn(async () => ({ themes: [] })), +})); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { POST } from '../route'; + +async function bundle(manifest: Record, code = 'module.exports = {};'): Promise { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify(manifest)); + zip.file('index.js', code); + const buf = await zip.generateAsync({ type: 'uint8array' }); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +function install(body: Record) { + return POST(new NextRequest('http://localhost/api/admin/marketplace', { + method: 'POST', + body: JSON.stringify(body), + headers: { 'content-type': 'application/json' }, + })); +} + +const MANIFEST = { + name: 'Pretty Dates', version: '1.0.0', author: 'x', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +beforeEach(() => { + registry.clear(); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('POST /api/admin/marketplace', () => { + it('installs a plugin under its directory slug', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(200); + expect(registry.has('pretty-dates')).toBe(true); + }); + + it('refuses a bundle whose manifest id differs from the slug', async () => { + // Otherwise a directory entry can overwrite another installed plugin, + // inheriting its enabled and force-enabled state, while the admin UI + // (which keys install status by slug) still shows "not installed". + registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' })))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/does not match/); + expect(registry.get('pgp-plugin')?.version).toBe('2.0.0'); + }); +}); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index b218c4ea3..1dfba54da 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -185,7 +185,16 @@ export async function POST(request: NextRequest) { // bundles are authored by a third-party publisher; without this an id // like "../../foo" causes savePlugin/saveTheme to write outside the // plugins/themes dir via path.join. - const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug; + // The slug is what the admin picked and what install status is keyed + // on. A bundle that names another id would install over whatever + // already uses that id, keeping its enabled and force-enabled state. + if (manifest.id !== undefined && manifest.id !== slug) { + return NextResponse.json( + { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` }, + { status: 400 }, + ); + } + const resolvedId = slug; if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) { return NextResponse.json( { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },