fix: refuse marketplace bundles whose id differs from the directory entry

This commit is contained in:
Linus Rath
2026-10-14 01:32:44 +02:00
parent c45533646e
commit 468c1aac0c
2 changed files with 97 additions and 1 deletions
@@ -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<string, Record<string, unknown>>();
vi.mock('@/lib/admin/plugin-registry', () => ({
savePlugin: vi.fn(async (p: Record<string, unknown>) => { 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<typeof import('@/lib/admin/csp-frame-origins')>()),
invalidateFrameOriginsCache: vi.fn(),
}));
import { POST } from '../route';
async function bundle(manifest: Record<string, unknown>, code = 'module.exports = {};'): Promise<ArrayBuffer> {
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<string, unknown>) {
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');
});
});
+10 -1
View File
@@ -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' },