diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index d62bacf60..3896f05fd 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -5,8 +5,7 @@ import { getPluginRegistry, getThemeRegistry, } from '@/lib/admin/plugin-registry'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { openPackage, readManifest, readText } from '@/lib/extension-package'; import { configManager } from '@/lib/admin/config-manager'; async function getDirectoryUrl(): Promise { @@ -79,61 +78,28 @@ export async function GET( } else { const buffer = await bundleRes.arrayBuffer(); bundleSize = buffer.byteLength; - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - if (buffer.byteLength > maxSize) { - bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`; - } else { - const zip = await JSZip.loadAsync(buffer); + // Opening the preview must not inflate an arbitrary bundle in + // full: every read goes through the capped package reader. + const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin'); + try { + manifest = await readManifest(pkg); + } catch (err) { + bundleError = err instanceof Error ? err.message : 'Invalid manifest.json in bundle'; + } - // Detect optional root directory inside the ZIP. - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map((e) => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - bundleError = 'Bundle missing manifest.json'; - } else { - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - bundleError = 'Invalid manifest.json in bundle'; - } - } - - if (manifest) { - if (type === 'theme') { - const cssFile = zip.file(root + 'theme.css'); - if (cssFile) { - const css = await cssFile.async('string'); - sourcePreview = { - name: 'theme.css', - content: css.length > MAX_PREVIEW_SOURCE_LEN - ? css.slice(0, MAX_PREVIEW_SOURCE_LEN) - : css, - truncated: css.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } else { - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (jsFile) { - const code = await jsFile.async('string'); - sourcePreview = { - name: entrypoint, - content: code.length > MAX_PREVIEW_SOURCE_LEN - ? code.slice(0, MAX_PREVIEW_SOURCE_LEN) - : code, - truncated: code.length > MAX_PREVIEW_SOURCE_LEN, - }; - } - } + if (manifest) { + const name = type === 'theme' + ? 'theme.css' + : (typeof manifest.entrypoint === 'string' && manifest.entrypoint) || 'index.js'; + const content = await readText(pkg, name); + if (content !== null) { + sourcePreview = { + name, + content: content.length > MAX_PREVIEW_SOURCE_LEN + ? content.slice(0, MAX_PREVIEW_SOURCE_LEN) + : content, + truncated: content.length > MAX_PREVIEW_SOURCE_LEN, + }; } } } diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts index 069a7242b..747e39e3c 100644 --- a/app/api/admin/marketplace/__tests__/route.test.ts +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -71,6 +71,19 @@ describe('POST /api/admin/marketplace', () => { expect(registry.has('pretty-dates')).toBe(true); }); + it('refuses a ZIP bomb from the directory', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' })); + zip.file('index.js', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit))); + + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + 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 diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 1dfba54da..f5659edff 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -12,14 +12,16 @@ import { type ServerPlugin, type ServerTheme, } from '@/lib/admin/plugin-registry'; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types'; + EXTENSION_ID_RE, + ExtensionPackageError, + openPackage, + readManifest, + readPluginPackage, + readText, + suspiciousCode, +} from '@/lib/extension-package'; import { sanitizeThemeCSS } from '@/lib/theme-loader'; import { configManager } from '@/lib/admin/config-manager'; @@ -123,6 +125,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid type' }, { status: 400 }); } + // The slug becomes a file name and the registry key; it must be a plain id. + if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) { + return NextResponse.json( + { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' }, + { status: 400 }, + ); + } + // Download the bundle from the directory const directoryUrl = await getDirectoryUrl(); const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl); @@ -138,79 +148,40 @@ export async function POST(request: NextRequest) { } const buffer = await bundleRes.arrayBuffer(); - const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; - - if (buffer.byteLength > maxSize) { - return NextResponse.json( - { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` }, - { status: 400 } - ); - } - - // Parse the ZIP - let zip: JSZip; - try { - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 }); - } - - // Find root directory - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 }); - } - const now = new Date().toISOString(); - // Resolve and strictly validate the id used as a filename. Marketplace - // 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. - // 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' }, - { status: 400 }, - ); - } - if (type === 'theme') { - // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { + let pkg; + let manifest: Record; + try { + pkg = await openPackage(buffer, 'theme'); + manifest = await readManifest(pkg); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + // 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; + + let css: string | null; + try { + css = await readText(pkg, 'theme.css'); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + if (css === null) { return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 }); } - let css = await cssFile.async('string'); - // Sanitize unconditionally: the safety check is advisory, and the // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; @@ -255,148 +226,81 @@ export async function POST(request: NextRequest) { ); return NextResponse.json({ success: true, theme, warnings, updated: isUpdate }); - } else { - // Plugin installation - // Read entrypoint JS - const entrypoint = (manifest.entrypoint as string) || 'index.js'; - const jsFile = zip.file(root + entrypoint); - if (!jsFile) { - return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 }); - } - - const code = await jsFile.async('string'); - - // Block plugins with dangerous JS patterns - const DANGEROUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, - ]; - const dangerousFindings: string[] = []; - for (const { pattern, label } of DANGEROUS_JS_PATTERNS) { - if (pattern.test(code)) dangerousFindings.push(label); - pattern.lastIndex = 0; - } - if (dangerousFindings.length > 0) { - return NextResponse.json( - { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` }, - { status: 400 }, - ); - } - - // Validate permissions - const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : []; - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknownPerms = permissions.filter(p => !validPerms.has(p)); - - const warnings: string[] = []; - if (unknownPerms.length > 0) { - warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`); - } - - // Plugins may declare iframe origins they need for embedded content. - // Anything that doesn't pass strict origin validation is silently - // dropped - the plugin still installs, but those origins are not - // added to the host CSP. - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const droppedFrameOrigins = Array.isArray(manifest.frameOrigins) - ? (manifest.frameOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v), - ) - : []; - if (droppedFrameOrigins.length > 0) { - warnings.push( - `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`, - ); - } - - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const droppedHttpOrigins = Array.isArray(manifest.httpOrigins) - ? (manifest.httpOrigins as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v), - ) - : []; - if (droppedHttpOrigins.length > 0) { - warnings.push( - `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`, - ); - } - - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths) - ? (manifest.apiPostPaths as unknown[]).filter( - (v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v), - ) - : []; - if (droppedApiPostPaths.length > 0) { - warnings.push( - `Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`, - ); - } - - const existingPlugin = await getPlugin(resolvedId); - const isUpdate = existingPlugin !== null; - - const plugin: ServerPlugin = { - id: resolvedId, - name: (manifest.name as string) || slug, - // See theme branch: trust the directory-published version, not - // manifest.version, so updates actually stick in the registry. - version: version || (manifest.version as string), - author: (manifest.author as string) || 'Unknown', - description: (manifest.description as string) || '', - type: (manifest.type as string) || 'hook', - ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions, - entrypoint, - enabled: existingPlugin?.enabled ?? true, - ...(existingPlugin?.forceEnabled !== undefined - ? { forceEnabled: existingPlugin.forceEnabled } - : {}), - installedAt: existingPlugin?.installedAt ?? now, - updatedAt: now, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - - }; - - await savePlugin(plugin, code); - invalidateFrameOriginsCache(); - await auditLog( - isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', - { - id: plugin.id, - name: plugin.name, - version: plugin.version, - slug, - frameOrigins: declaredFrameOrigins, - httpOrigins: declaredHttpOrigins, - apiPostPaths: declaredApiPostPaths, - ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), - }, - ip, - ); - - return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate }); } + + // Plugin installation + let pkg; + try { + pkg = await readPluginPackage(buffer, { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + expectedId: slug, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + // Same wording the admin UI already shows for an id mismatch. + const message = err.message.replace(/Package id "([^"]*)" does not match "([^"]*)"/, 'Bundle id "$1" does not match the directory entry "$2"'); + return NextResponse.json({ error: message }, { status: 400 }); + } + throw err; + } + const { manifest, code } = pkg; + + // Block plugins with dangerous JS patterns + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { + return NextResponse.json( + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, + { status: 400 }, + ); + } + + const existingPlugin = await getPlugin(slug); + const isUpdate = existingPlugin !== null; + + const plugin: ServerPlugin = { + id: slug, + name: manifest.name, + // See theme branch: trust the directory-published version, not + // manifest.version, so updates actually stick in the registry. + version: version || manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, + ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, + enabled: existingPlugin?.enabled ?? true, + ...(existingPlugin?.forceEnabled !== undefined + ? { forceEnabled: existingPlugin.forceEnabled } + : {}), + installedAt: existingPlugin?.installedAt ?? now, + updatedAt: now, + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + }; + + await savePlugin(plugin, code); + invalidateFrameOriginsCache(); + await auditLog( + isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin', + { + id: plugin.id, + name: plugin.name, + version: plugin.version, + slug, + frameOrigins: plugin.frameOrigins ?? [], + httpOrigins: plugin.httpOrigins ?? [], + apiPostPaths: plugin.apiPostPaths ?? [], + ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), + }, + ip, + ); + + return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate }); } catch (error) { logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Installation failed' }, { status: 500 }); diff --git a/app/api/admin/plugins/__tests__/route.test.ts b/app/api/admin/plugins/__tests__/route.test.ts new file mode 100644 index 000000000..264a0badd --- /dev/null +++ b/app/api/admin/plugins/__tests__/route.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } 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 () => {}) })); + +let policy: Record; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: vi.fn(async () => {}), + get: vi.fn(), + getPolicy: vi.fn(() => policy), + setPolicy: vi.fn(async (p: Record) => { policy = p; }), + }, +})); + +const registry = new Map>(); +const removedConfig: string[] = []; +vi.mock('@/lib/admin/plugin-registry', () => ({ + savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }), + getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null), + getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })), + deletePlugin: vi.fn(async (id: string) => registry.delete(id)), +})); +vi.mock('@/lib/admin/plugin-config', () => ({ + deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }), +})); +vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) })); +vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({ + ...(await orig()), + invalidateFrameOriginsCache: vi.fn(), +})); + +import { POST } from '../route'; + +const MANIFEST = { + id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function upload(files: Record) { + const zip = new JSZip(); + for (const [n, d] of Object.entries(files)) zip.file(n, d); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' })); + return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form })); +} + +beforeEach(() => { + registry.clear(); + removedConfig.length = 0; + policy = { forceEnabledPlugins: [] }; +}); + +describe('POST /api/admin/plugins', () => { + it('installs a valid plugin with its locales', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }), + 'index.js': 'module.exports = {}', + }); + + expect(res.status).toBe(200); + expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } }); + }); + + it('refuses permissions given as a string', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + expect(registry.size).toBe(0); + }); + + it('refuses a ZIP bomb instead of inflating it', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify(MANIFEST), + 'index.js': new Uint8Array(30 * 1024 * 1024), + }); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); + + it('refuses an invalid settings schema', async () => { + const res = await upload({ + 'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }), + 'index.js': '', + }); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index a62c4bf81..41542a25b 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -9,25 +9,9 @@ import { type ServerPlugin, } from '@/lib/admin/plugin-registry'; import { listDevPlugins } from '@/lib/admin/plugin-dev'; -import { - sanitizeFrameOrigins, - sanitizeHttpOrigins, - sanitizeApiPostPaths, - invalidateFrameOriginsCache, -} from '@/lib/admin/csp-frame-origins'; - -// Server-side extraction using the same validation logic -// ZIP parsing needs to happen on the server for admin-uploaded plugins -import JSZip from 'jszip'; -import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types'; - -const SUSPICIOUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, -]; +import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins'; +import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package'; +import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types'; /** * GET /api/admin/plugins - List all admin-managed plugins @@ -79,141 +63,59 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 }); } - // Extract and validate ZIP - let zip: JSZip; + let pkg; try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; + pkg = await readPluginPackage(await file.arrayBuffer(), { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }); + } catch (err) { + if (err instanceof ExtensionPackageError) { + return NextResponse.json({ error: err.message }, { status: 400 }); } + throw err; } + const { manifest, code } = pkg; - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - - let manifest: Record; - try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); - } - - // Validate manifest - const errors: string[] = []; - if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"'); - if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"'); - if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"'); - if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"'); - if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"'); - - const validTypes = ['ui-extension', 'sidebar-app', 'hook']; - if (!validTypes.includes(manifest.type as string)) { - errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`); - } - - if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) { - errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); - } - - if (manifest.permissions && Array.isArray(manifest.permissions)) { - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); - if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`); - } - - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Check file extensions - for (const [filePath, entry] of Object.entries(zip.files)) { - if (entry.dir) continue; - const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : ''; - if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { - errors.push(`Disallowed file type: ${filePath}`); - } - } - if (errors.length > 0) { - return NextResponse.json({ error: errors.join('; ') }, { status: 400 }); - } - - // Read entrypoint code - const entryFile = zip.file(root + (manifest.entrypoint as string)); - if (!entryFile) { - return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 }); - } - const code = await entryFile.async('string'); - - // Security: block plugins containing dangerous JS patterns - const warnings: string[] = []; - for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { - if (pattern.test(code)) warnings.push(`Contains ${label}`); - pattern.lastIndex = 0; - } - if (warnings.length > 0) { + // Admin uploads reach every user, so the patterns a user upload only + // warns about are refused here. + const suspicious = suspiciousCode(code); + if (suspicious.length > 0) { return NextResponse.json( - { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` }, + { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` }, { status: 400 }, ); } - const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); - const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); - const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths); - const now = new Date().toISOString(); const plugin: ServerPlugin = { - id: manifest.id as string, - name: manifest.name as string, - version: manifest.version as string, - author: manifest.author as string, - description: (manifest.description as string) || '', - type: manifest.type as string, + id: manifest.id, + name: manifest.name, + version: manifest.version, + author: manifest.author, + description: manifest.description, + type: manifest.type, ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}), - permissions: (manifest.permissions as string[]) || [], - entrypoint: manifest.entrypoint as string, + permissions: manifest.permissions, + entrypoint: manifest.entrypoint, enabled: true, - ...(manifest.configSchema && typeof manifest.configSchema === 'object' - ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } - : {}), - ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object' - ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] } - : {}), - ...(manifest.locales && typeof manifest.locales === 'object' - ? { locales: manifest.locales as ServerPlugin['locales'] } - : {}), - ...(declaredFrameOrigins.length > 0 - ? { frameOrigins: declaredFrameOrigins } - : {}), - ...(declaredHttpOrigins.length > 0 - ? { httpOrigins: declaredHttpOrigins } - : {}), - ...(declaredApiPostPaths.length > 0 - ? { apiPostPaths: declaredApiPostPaths } - : {}), + ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}), + ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}), + ...(manifest.locales ? { locales: manifest.locales } : {}), + ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), + ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), + ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), installedAt: now, updatedAt: now, }; + const declaredFrameOrigins = plugin.frameOrigins ?? []; + const declaredHttpOrigins = plugin.httpOrigins ?? []; + const declaredApiPostPaths = plugin.apiPostPaths ?? []; await savePlugin(plugin, code); invalidateFrameOriginsCache(); await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip); - return NextResponse.json({ plugin }); + return NextResponse.json({ plugin, warnings: pkg.warnings }); } catch (error) { logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts index 6f11c7a7d..3a092fba2 100644 --- a/app/api/admin/themes/__tests__/route.test.ts +++ b/app/api/admin/themes/__tests__/route.test.ts @@ -26,7 +26,8 @@ vi.mock('@/lib/admin/plugin-registry', () => ({ getThemeRegistry: vi.fn(async () => ({ themes: [] })), })); -import { DELETE } from '../route'; +import JSZip from 'jszip'; +import { DELETE, POST } from '../route'; function del(id: string) { return DELETE(new NextRequest('http://localhost/api/admin/themes', { @@ -48,3 +49,19 @@ describe('DELETE /api/admin/themes', () => { expect(policy.forceEnabledThemes).toEqual(['other']); }); }); + +describe('POST /api/admin/themes', () => { + it('refuses a theme.css that unpacks past the limit', async () => { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ id: 'big', name: 'Big', version: '1.0.0', author: 'a', type: 'theme', variants: ['light'] })); + zip.file('theme.css', new Uint8Array(30 * 1024 * 1024)); + const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); + const form = new FormData(); + form.append('file', new File([bytes as BlobPart], 't.zip')); + + const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form })); + + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/once unpacked/); + }); +}); diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index a7869c0c1..8f4cfbec9 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -10,8 +10,7 @@ import { } from '@/lib/admin/plugin-registry'; import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup'; -import JSZip from 'jszip'; -import { MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { ExtensionPackageError, openPackage, readManifest, readText, type OpenedPackage } from '@/lib/extension-package'; import { sanitizeThemeCSS } from '@/lib/theme-loader'; /** @@ -48,41 +47,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing file' }, { status: 400 }); } - if (file.size > MAX_THEME_SIZE) { - return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 }); - } - - // Extract and validate ZIP - let zip: JSZip; - try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 }); - } - - // Find root - const entries = Object.keys(zip.files); - const topDirs = new Set(entries.map(e => e.split('/')[0])); - let root = ''; - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - root = dir + '/'; - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 }); - } - + let pkg: OpenedPackage; let manifest: Record; try { - manifest = JSON.parse(await manifestFile.async('string')); - } catch { - return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 }); + pkg = await openPackage(await file.arrayBuffer(), 'theme'); + manifest = await readManifest(pkg); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; } // Validate manifest @@ -112,13 +84,17 @@ export async function POST(request: NextRequest) { } // Read theme.css - const cssFile = zip.file(root + 'theme.css'); - if (!cssFile) { + let css: string | null; + try { + css = await readText(pkg, 'theme.css'); + } catch (err) { + if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 }); + throw err; + } + if (css === null) { return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 }); } - let css = await cssFile.async('string'); - // Sanitize unconditionally: the safety check is advisory, and the // sanitizer is also what drops rules outside :root and .dark. const warnings: string[] = []; diff --git a/lib/__tests__/extension-package.test.ts b/lib/__tests__/extension-package.test.ts new file mode 100644 index 000000000..ce0945a0d --- /dev/null +++ b/lib/__tests__/extension-package.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { + checkPluginManifest, + compareVersions, + readPluginPackage, + MAX_ENTRY_BYTES, + MAX_ZIP_ENTRIES, +} from '../extension-package'; + +const BASE = { + id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd', + type: 'hook', permissions: ['email:read'], entrypoint: 'index.js', +}; + +async function zipOf(files: Record): Promise { + const zip = new JSZip(); + for (const [name, data] of Object.entries(files)) zip.file(name, data); + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} + +describe('checkPluginManifest', () => { + it('accepts a normal manifest and keeps only checked fields', () => { + const { manifest, errors } = checkPluginManifest({ ...BASE, somethingElse: 1, locales: { de: { a: 'b' } } }); + + expect(errors).toEqual([]); + expect(manifest).toMatchObject({ id: 'demo-plugin', permissions: ['email:read'], locales: { de: { a: 'b' } } }); + expect(manifest).not.toHaveProperty('somethingElse'); + }); + + it('refuses permissions given as a string', () => { + // As a string, "crypto:full email:send" used to pass validation and then + // grant both through String.prototype.includes. + const { manifest, errors } = checkPluginManifest({ ...BASE, permissions: 'crypto:full email:send' }); + + expect(manifest).toBeNull(); + expect(errors.join()).toMatch(/must be a list of strings/); + }); + + it('refuses unknown permissions', () => { + expect(checkPluginManifest({ ...BASE, permissions: ['email:read', 'root:all'] }).errors.join()) + .toMatch(/Unknown permissions: root:all/); + }); + + it.each(['../index.js', '/index.js', 'a/../../x.js', 'index.css', 'a\\b.js'])('refuses entrypoint %s', (entrypoint) => { + expect(checkPluginManifest({ ...BASE, entrypoint }).manifest).toBeNull(); + }); + + it('refuses a select setting whose options are not a list', () => { + const { errors } = checkPluginManifest({ + ...BASE, + settingsSchema: { mode: { type: 'select', label: 'Mode', default: 'a', options: 'a,b' } }, + }); + + expect(errors.join()).toMatch(/settingsSchema\.mode\.options/); + }); + + it('refuses a number default outside min/max', () => { + const { errors } = checkPluginManifest({ + ...BASE, + settingsSchema: { size: { type: 'number', label: 'Size', default: 900, min: 16, max: 512 } }, + }); + + expect(errors.join()).toMatch(/outside min\/max/); + }); + + it('drops invalid origins with a warning instead of storing them', () => { + const { manifest, warnings } = checkPluginManifest({ + ...BASE, + permissions: ['http:fetch'], + httpOrigins: ['https://api.example.com', 'https://*.com', 'http://plain.example'], + }); + + expect(manifest?.httpOrigins).toEqual(['https://api.example.com']); + expect(warnings.join()).toMatch(/\*\.com/); + }); + + it('refuses a plugin that needs a newer Bulwark', () => { + expect(checkPluginManifest({ ...BASE, minAppVersion: '9.0.0' }, { appVersion: '1.9.2' }).errors.join()) + .toMatch(/Needs Bulwark 9\.0\.0/); + }); + + it('refuses a privileged plugin without crypto:full', () => { + expect(checkPluginManifest({ ...BASE, tier: 'privileged' }).manifest).toBeNull(); + }); +}); + +describe('readPluginPackage', () => { + it('reads manifest and code from a package in a sub folder', async () => { + const data = await zipOf({ 'demo/manifest.json': JSON.stringify(BASE), 'demo/index.js': 'module.exports = {}' }); + + const pkg = await readPluginPackage(data); + + expect(pkg.code).toBe('module.exports = {}'); + expect(pkg.manifest.id).toBe('demo-plugin'); + }); + + it('stops inflating a file that unpacks past the limit (ZIP bomb)', async () => { + // ~25 KB compressed, more than the per-file cap once inflated. + const data = await zipOf({ + 'manifest.json': JSON.stringify(BASE), + 'index.js': new Uint8Array(MAX_ENTRY_BYTES + 1024), + }); + expect(data.byteLength).toBeLessThan(200 * 1024); + + await expect(readPluginPackage(data)).rejects.toThrow(/larger than .* once unpacked/); + }); + + it('refuses a ZIP with too many entries', async () => { + const files: Record = { 'manifest.json': JSON.stringify(BASE), 'index.js': '' }; + for (let i = 0; i < MAX_ZIP_ENTRIES; i++) files[`f${i}.json`] = '{}'; + + await expect(readPluginPackage(await zipOf(files))).rejects.toThrow(/entries/); + }); + + it('refuses disallowed file types', async () => { + const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '', 'run.exe': 'MZ' }); + + await expect(readPluginPackage(data)).rejects.toThrow(/run\.exe/); + }); + + it('refuses a package whose id is not the expected one', async () => { + const data = await zipOf({ 'manifest.json': JSON.stringify(BASE), 'index.js': '' }); + + await expect(readPluginPackage(data, { expectedId: 'other' })).rejects.toThrow(/does not match/); + }); +}); + +describe('compareVersions', () => { + it('compares numerically', () => { + expect(compareVersions('1.10.0', '1.9.9')).toBe(1); + expect(compareVersions('1.2.0', '1.2.0-beta.1')).toBe(0); + expect(compareVersions('0.9.0', '1.0.0')).toBe(-1); + }); +}); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index d2376ea17..1e1a791cb 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -221,3 +221,25 @@ describe('usePluginStore', () => { }); }); }); + +describe('installPlugin', () => { + beforeEach(() => { + resetStore(); + }); + + it('keeps the translations a plugin ships', async () => { + const { extractPlugin } = await import('@/lib/plugin-validator'); + vi.mocked(extractPlugin).mockResolvedValueOnce({ + valid: true, errors: [], warnings: [], preview: null, code: 'module.exports = {}', + manifest: { + id: 'translated', name: 'T', version: '1.0.0', author: 'a', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', locales: { de: { hello: 'Hallo' } }, + }, + }); + + const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip')); + + expect(result.success).toBe(true); + expect(usePluginStore.getState().plugins[0].locales).toEqual({ de: { hello: 'Hallo' } }); + }); +}); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 0eb5e2d95..9e02c7f1b 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -10,84 +10,15 @@ */ import { getPluginRegistry } from './plugin-registry'; +import { sanitizeFrameOrigins } from '../extension-origins'; -// `https://host`, `https://host:port`, or `https://*.host[:port]` -// -// Each label is alphanumeric with optional inner dashes; the final TLD label -// MUST start with a letter so we reject raw IPv4 literals. -// -// Disallowed by the regex (intentionally): -// - any scheme other than https -// - paths, queries, fragments -// - userinfo, IPv4 literals, IPv6 literals (`[::1]`) -// - bare wildcards (`https://*`) -const FRAME_ORIGIN_RE = - /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i; - -export function isValidFrameOrigin(origin: unknown): origin is string { - if (typeof origin !== 'string') return false; - if (origin.length > 200) return false; - if (!FRAME_ORIGIN_RE.test(origin)) return false; - // Reject control characters / whitespace as a final safeguard against - // anything that would let an attacker break out of the directive. - if (/[\s'"`;,()]/.test(origin)) return false; - return true; -} - -/** - * Sanitises a list of candidate origins from a manifest. Drops invalid - * entries silently and dedupes (case-insensitive on the host). - */ -export function sanitizeFrameOrigins(input: unknown): string[] { - if (!Array.isArray(input)) return []; - const seen = new Set(); - const out: string[] = []; - for (const value of input) { - if (!isValidFrameOrigin(value)) continue; - const key = value.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(value); - } - return out; -} - -/** - * Same syntax + validation as `sanitizeFrameOrigins`, but for the - * `httpOrigins` manifest field. Kept as a separate exported function so the - * intent is explicit at every call site (frame embedding vs. HTTP fetch). - */ -export const sanitizeHttpOrigins = sanitizeFrameOrigins; - -// ─── apiPostPaths (manifest field) ──────────────────────────── - -/** - * Validates an `/api/...` path entry. Must start with `/api/`, contain only - * URL-path-safe characters, and have no `..` segment. The trailing slash is - * meaningful (treated as a prefix at enforcement time). - */ -export function isValidApiPostPath(path: unknown): path is string { - if (typeof path !== 'string') return false; - if (path.length === 0 || path.length > 200) return false; - if (!path.startsWith('/api/')) return false; - if (path.includes('..')) return false; - if (/[\s'"`;,()?#]/.test(path)) return false; - if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false; - return true; -} - -export function sanitizeApiPostPaths(input: unknown): string[] { - if (!Array.isArray(input)) return []; - const seen = new Set(); - const out: string[] = []; - for (const value of input) { - if (!isValidApiPostPath(value)) continue; - if (seen.has(value)) continue; - seen.add(value); - out.push(value); - } - return out; -} +export { + isValidFrameOrigin, + sanitizeFrameOrigins, + sanitizeHttpOrigins, + isValidApiPostPath, + sanitizeApiPostPaths, +} from '../extension-origins'; // In-memory cache. The proxy fires on every page navigation; reading the // registry JSON every time is fine but cheap to skip when nothing has diff --git a/lib/extension-origins.ts b/lib/extension-origins.ts new file mode 100644 index 000000000..a30299ac5 --- /dev/null +++ b/lib/extension-origins.ts @@ -0,0 +1,81 @@ +// Manifest origin and path validation shared by the client install path, +// the admin and marketplace routes and the CSP builder. No server imports, +// so the browser can use it too. + +// `https://host`, `https://host:port`, or `https://*.host[:port]` +// +// Each label is alphanumeric with optional inner dashes; the final TLD label +// MUST start with a letter so we reject raw IPv4 literals. +// +// Disallowed by the regex (intentionally): +// - any scheme other than https +// - paths, queries, fragments +// - userinfo, IPv4 literals, IPv6 literals (`[::1]`) +// - bare wildcards (`https://*`) +const FRAME_ORIGIN_RE = + /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i; + +export function isValidFrameOrigin(origin: unknown): origin is string { + if (typeof origin !== 'string') return false; + if (origin.length > 200) return false; + if (!FRAME_ORIGIN_RE.test(origin)) return false; + // Reject control characters / whitespace as a final safeguard against + // anything that would let an attacker break out of the directive. + if (/[\s'"`;,()]/.test(origin)) return false; + return true; +} + +/** + * Sanitises a list of candidate origins from a manifest. Drops invalid + * entries silently and dedupes (case-insensitive on the host). + */ +export function sanitizeFrameOrigins(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of input) { + if (!isValidFrameOrigin(value)) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(value); + } + return out; +} + +/** + * Same syntax + validation as `sanitizeFrameOrigins`, but for the + * `httpOrigins` manifest field. Kept as a separate exported function so the + * intent is explicit at every call site (frame embedding vs. HTTP fetch). + */ +export const sanitizeHttpOrigins = sanitizeFrameOrigins; + +// ─── apiPostPaths (manifest field) ──────────────────────────── + +/** + * Validates an `/api/...` path entry. Must start with `/api/`, contain only + * URL-path-safe characters, and have no `..` segment. The trailing slash is + * meaningful (treated as a prefix at enforcement time). + */ +export function isValidApiPostPath(path: unknown): path is string { + if (typeof path !== 'string') return false; + if (path.length === 0 || path.length > 200) return false; + if (!path.startsWith('/api/')) return false; + if (path.includes('..')) return false; + if (/[\s'"`;,()?#]/.test(path)) return false; + if (!/^[/A-Za-z0-9._-]+$/.test(path)) return false; + return true; +} + +export function sanitizeApiPostPaths(input: unknown): string[] { + if (!Array.isArray(input)) return []; + const seen = new Set(); + const out: string[] = []; + for (const value of input) { + if (!isValidApiPostPath(value)) continue; + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} diff --git a/lib/extension-package.ts b/lib/extension-package.ts new file mode 100644 index 000000000..c2c577771 --- /dev/null +++ b/lib/extension-package.ts @@ -0,0 +1,508 @@ +// Reading and validating plugin and theme packages (ZIP + manifest.json). +// +// One implementation for every install path: the browser upload in Settings, +// the admin upload, the marketplace install and the marketplace preview. The +// copies these replace had drifted apart: some checked file types, some the +// permission list, some the origin lists, and none bounded how much a ZIP may +// inflate to. + +import JSZip from 'jszip'; +import { + ALL_PERMISSIONS, + ALLOWED_PLUGIN_FILES, + MAX_PLUGIN_SIZE, + MAX_THEME_SIZE, + type PluginManifest, + type PluginTier, + type PluginType, + type SettingFieldSchema, +} from './plugin-types'; +import { sanitizeApiPostPaths, sanitizeFrameOrigins, sanitizeHttpOrigins } from './extension-origins'; + +export const EXTENSION_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +/** No real plugin or theme needs more files than this. */ +export const MAX_ZIP_ENTRIES = 200; +/** Largest single file we inflate. Bundles are capped at 5 MB zipped. */ +export const MAX_ENTRY_BYTES = 20 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 512 * 1024; + +export type ExtensionKind = 'plugin' | 'theme'; + +export class ExtensionPackageError extends Error {} + +export interface OpenedPackage { + zip: JSZip; + /** Folder prefix when every entry sits inside one top-level directory. */ + root: string; +} + +function toUint8(data: ArrayBuffer | Uint8Array): Uint8Array { + return data instanceof Uint8Array ? data : new Uint8Array(data); +} + +/** + * Open a package ZIP. Checks the compressed size and entry count; files are + * only inflated when read (see `readEntry`), each under a hard cap. + */ +export async function openPackage(data: ArrayBuffer | Uint8Array, kind: ExtensionKind): Promise { + const bytes = toUint8(data); + const max = kind === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE; + if (bytes.byteLength > max) { + throw new ExtensionPackageError(`${kind === 'theme' ? 'Theme' : 'Plugin'} ZIP exceeds ${Math.round(max / (1024 * 1024))} MB size limit`); + } + let zip: JSZip; + try { + zip = await JSZip.loadAsync(bytes); + } catch { + throw new ExtensionPackageError('Invalid ZIP file'); + } + const names = Object.keys(zip.files); + if (names.length > MAX_ZIP_ENTRIES) { + throw new ExtensionPackageError(`ZIP has ${names.length} entries; at most ${MAX_ZIP_ENTRIES} are allowed`); + } + return { zip, root: findRoot(zip) }; +} + +function findRoot(zip: JSZip): string { + const entries = Object.keys(zip.files); + const topDirs = new Set(entries.map((e) => e.split('/')[0])); + if (topDirs.size === 1) { + const dir = [...topDirs][0]; + if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) return dir + '/'; + } + return ''; +} + +/** + * Inflate one entry, stopping as soon as it passes `maxBytes`. The size in + * the ZIP header is whatever the author wrote, so it can't be trusted to + * stop a ZIP bomb; counting the inflated bytes can. + */ +export async function readEntry(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise { + const entry = pkg.zip.file(pkg.root + name); + if (!entry || entry.dir) return null; + return new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + let total = 0; + let done = false; + // internalStream is part of JSZip's API but missing from its typings. + const stream = (entry as unknown as { internalStream(type: 'uint8array'): JSZipStream }).internalStream('uint8array'); + stream + .on('data', (chunk: Uint8Array) => { + if (done) return; + total += chunk.byteLength; + if (total > maxBytes) { + done = true; + stream.pause(); + reject(new ExtensionPackageError(`${name} is larger than ${Math.round(maxBytes / 1024)} KB once unpacked`)); + return; + } + chunks.push(chunk); + }) + .on('error', (err: Error) => { + if (done) return; + done = true; + reject(new ExtensionPackageError(`Could not read ${name}: ${err.message}`)); + }) + .on('end', () => { + if (done) return; + done = true; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + resolve(out); + }) + .resume(); + }); +} + +interface JSZipStream { + on(event: 'data', cb: (chunk: Uint8Array) => void): JSZipStream; + on(event: 'error', cb: (err: Error) => void): JSZipStream; + on(event: 'end', cb: () => void): JSZipStream; + pause(): JSZipStream; + resume(): JSZipStream; +} + +export async function readText(pkg: OpenedPackage, name: string, maxBytes = MAX_ENTRY_BYTES): Promise { + const bytes = await readEntry(pkg, name, maxBytes); + return bytes === null ? null : new TextDecoder('utf-8').decode(bytes); +} + +export async function readManifest(pkg: OpenedPackage): Promise> { + const raw = await readText(pkg, 'manifest.json', MAX_MANIFEST_BYTES); + if (raw === null) throw new ExtensionPackageError('Missing manifest.json'); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new ExtensionPackageError('Invalid manifest.json (not valid JSON)'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ExtensionPackageError('manifest.json must be a JSON object'); + } + return parsed as Record; +} + +/** Files in a plugin package whose type the host refuses. */ +export function disallowedPluginFiles(pkg: OpenedPackage): string[] { + const out: string[] = []; + for (const [path, entry] of Object.entries(pkg.zip.files)) { + if (entry.dir) continue; + const dot = path.lastIndexOf('.'); + const ext = dot >= 0 ? path.slice(dot).toLowerCase() : ''; + if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) out.push(path); + } + return out; +} + +// ─── Manifest validation ───────────────────────────────────── + +export interface ManifestCheck { + manifest: T | null; + errors: string[]; + warnings: string[]; +} + +type InstallablePluginType = Exclude; +const PLUGIN_TYPES: InstallablePluginType[] = ['ui-extension', 'sidebar-app', 'hook']; +const KNOWN_PERMISSIONS = new Set(ALL_PERMISSIONS); + +function requireString(m: Record, key: string, errors: string[], max = 200): string { + const v = m[key]; + if (typeof v !== 'string' || v.trim() === '') { + errors.push(`Missing or invalid "${key}"`); + return ''; + } + if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`); + return v; +} + +function optionalString(m: Record, key: string, errors: string[], max = 2000): string | undefined { + const v = m[key]; + if (v === undefined) return undefined; + if (typeof v !== 'string') { + errors.push(`"${key}" must be a string`); + return undefined; + } + if (v.length > max) errors.push(`"${key}" is longer than ${max} characters`); + return v; +} + +/** `entrypoint` must name a .js file inside the package. */ +function isSafeEntrypoint(value: string): boolean { + if (value.startsWith('/') || value.includes('\\')) return false; + if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false; + return /\.(m?js)$/.test(value) && value.length <= 200; +} + +const VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; + +/** Compare dotted versions numerically; pre-release tags are ignored. */ +export function compareVersions(a: string, b: string): number { + const pa = a.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0); + const pb = b.split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d < 0 ? -1 : 1; + } + return 0; +} + +function validateSettingsSchema(raw: unknown, errors: string[]): Record | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"settingsSchema" must be an object'); + return undefined; + } + const out: Record = {}; + for (const [key, field] of Object.entries(raw as Record)) { + const where = `settingsSchema.${key}`; + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + errors.push(`${where}: key must be letters, digits and _`); + continue; + } + if (!field || typeof field !== 'object' || Array.isArray(field)) { + errors.push(`${where} must be an object`); + continue; + } + const f = field as Record; + const type = f.type; + if (type !== 'boolean' && type !== 'string' && type !== 'number' && type !== 'select') { + errors.push(`${where}.type must be boolean, string, number or select`); + continue; + } + if (typeof f.label !== 'string' || !f.label) { + errors.push(`${where}.label is required`); + continue; + } + if (f.description !== undefined && typeof f.description !== 'string') { + errors.push(`${where}.description must be a string`); + continue; + } + const entry: SettingFieldSchema = { type, label: f.label, default: f.default }; + if (typeof f.description === 'string') entry.description = f.description; + if (type === 'select') { + if (!Array.isArray(f.options) || f.options.length === 0 || f.options.some((o) => typeof o !== 'string')) { + errors.push(`${where}.options must be a non-empty list of strings`); + continue; + } + entry.options = f.options as string[]; + if (!entry.options.includes(f.default as string)) { + errors.push(`${where}.default must be one of its options`); + continue; + } + } else if (type === 'boolean') { + if (typeof f.default !== 'boolean') { + errors.push(`${where}.default must be true or false`); + continue; + } + } else if (type === 'string') { + if (typeof f.default !== 'string') { + errors.push(`${where}.default must be a string`); + continue; + } + } else if (type === 'number') { + for (const bound of ['min', 'max'] as const) { + if (f[bound] !== undefined && (typeof f[bound] !== 'number' || !Number.isFinite(f[bound]))) { + errors.push(`${where}.${bound} must be a number`); + } else if (typeof f[bound] === 'number') { + entry[bound] = f[bound] as number; + } + } + if (typeof f.default !== 'number' || !Number.isFinite(f.default)) { + errors.push(`${where}.default must be a number`); + continue; + } + if ((entry.min !== undefined && f.default < entry.min) || (entry.max !== undefined && f.default > entry.max)) { + errors.push(`${where}.default is outside min/max`); + continue; + } + } + out[key] = entry; + } + return out; +} + +const CONFIG_FIELD_TYPES = new Set(['string', 'secret', 'boolean', 'number', 'select']); + +/** Admin-level setting a plugin declares (`configSchema`); mirrors PluginConfigField. */ +export interface ConfigFieldSchema { + type: 'string' | 'secret' | 'boolean' | 'number' | 'select'; + label: string; + description?: string; + required?: boolean; + default?: unknown; + placeholder?: string; + options?: { label: string; value: string }[]; +} + +function validateConfigSchema(raw: unknown, errors: string[]): Record | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"configSchema" must be an object'); + return undefined; + } + const out: Record = {}; + for (const [key, field] of Object.entries(raw as Record)) { + const where = `configSchema.${key}`; + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + errors.push(`${where}: key must be letters, digits and _`); + continue; + } + const f = field as Record | null; + if (!f || typeof f !== 'object' || Array.isArray(f) || !CONFIG_FIELD_TYPES.has(f.type as string) || typeof f.label !== 'string') { + errors.push(`${where} needs a type (string, secret, boolean, number, select) and a label`); + continue; + } + if (f.type === 'select' && (!Array.isArray(f.options) || f.options.some((o) => !o || typeof (o as { value?: unknown }).value !== 'string'))) { + errors.push(`${where}.options must be a list of { label, value }`); + continue; + } + out[key] = f as unknown as ConfigFieldSchema; + } + return out; +} + +const MAX_LOCALES = 64; +const MAX_LOCALE_KEYS = 2000; + +function validateLocales(raw: unknown, errors: string[]): Record> | undefined { + if (raw === undefined) return undefined; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push('"locales" must be an object of locale → { key: text }'); + return undefined; + } + const entries = Object.entries(raw as Record); + if (entries.length > MAX_LOCALES) { + errors.push(`"locales" has more than ${MAX_LOCALES} languages`); + return undefined; + } + const out: Record> = {}; + for (const [locale, table] of entries) { + if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale)) { + errors.push(`locales: "${locale}" is not a language tag`); + continue; + } + if (!table || typeof table !== 'object' || Array.isArray(table)) { + errors.push(`locales.${locale} must be an object`); + continue; + } + const strings = Object.entries(table as Record); + if (strings.length > MAX_LOCALE_KEYS || strings.some(([, v]) => typeof v !== 'string')) { + errors.push(`locales.${locale} must map at most ${MAX_LOCALE_KEYS} keys to strings`); + continue; + } + out[locale] = table as Record; + } + return out; +} + +function originList( + m: Record, + key: 'frameOrigins' | 'httpOrigins' | 'apiPostPaths', + sanitize: (v: unknown) => string[], + errors: string[], + warnings: string[], +): string[] | undefined { + const raw = m[key]; + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) { + errors.push(`"${key}" must be a list`); + return undefined; + } + const kept = sanitize(raw); + const dropped = raw.filter((v) => typeof v !== 'string' || !kept.includes(v)); + if (dropped.length > 0) warnings.push(`Ignored invalid ${key}: ${dropped.map(String).join(', ')}`); + return kept.length > 0 ? kept : undefined; +} + +/** What every install path stores for a plugin, after validation. */ +export type ValidatedPluginManifest = PluginManifest & { + frameOrigins?: string[]; + configSchema?: Record; +}; + +/** + * Validate a plugin manifest. Returns a copy holding only known, checked + * fields, so nothing the host didn't validate is ever stored. + */ +export function checkPluginManifest(m: Record, opts: { appVersion?: string } = {}): ManifestCheck { + const errors: string[] = []; + const warnings: string[] = []; + + const id = requireString(m, 'id', errors, 64); + if (id && !EXTENSION_ID_RE.test(id)) errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars'); + const name = requireString(m, 'name', errors, 100); + const version = requireString(m, 'version', errors, 64); + if (version && !VERSION_RE.test(version)) errors.push('"version" must look like 1.2.3'); + const author = requireString(m, 'author', errors, 200); + const description = optionalString(m, 'description', errors) ?? ''; + + const type = m.type as InstallablePluginType; + if (!PLUGIN_TYPES.includes(type)) errors.push(`Invalid type "${String(m.type)}". Must be one of: ${PLUGIN_TYPES.join(', ')}`); + + let tier: PluginTier | undefined; + if (m.tier !== undefined) { + if (m.tier !== 'untrusted' && m.tier !== 'privileged') errors.push('"tier" must be "untrusted" or "privileged"'); + else tier = m.tier; + } + + const entrypoint = typeof m.entrypoint === 'string' && m.entrypoint ? m.entrypoint : 'index.js'; + if (!isSafeEntrypoint(entrypoint)) errors.push('"entrypoint" must be a .js file inside the package'); + + // A string here used to slip past every check and then match by substring + // ("crypto:full email:send" granted both). + let permissions: string[] = []; + if (m.permissions !== undefined) { + if (!Array.isArray(m.permissions) || m.permissions.some((p) => typeof p !== 'string')) { + errors.push('"permissions" must be a list of strings'); + } else { + permissions = [...new Set(m.permissions as string[])]; + const unknown = permissions.filter((p) => !KNOWN_PERMISSIONS.has(p)); + if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`); + } + } + if (tier === 'privileged' && !permissions.includes('crypto:full')) { + errors.push('A privileged plugin must request the "crypto:full" permission'); + } + + const minAppVersion = optionalString(m, 'minAppVersion', errors, 32); + if (minAppVersion !== undefined && !VERSION_RE.test(minAppVersion)) errors.push('"minAppVersion" must look like 1.2.3'); + if (minAppVersion && opts.appVersion && VERSION_RE.test(opts.appVersion) && compareVersions(opts.appVersion, minAppVersion) < 0) { + errors.push(`Needs Bulwark ${minAppVersion} or later (this is ${opts.appVersion})`); + } + + const settingsSchema = validateSettingsSchema(m.settingsSchema, errors); + const configSchema = validateConfigSchema(m.configSchema, errors); + const locales = validateLocales(m.locales, errors); + const frameOrigins = originList(m, 'frameOrigins', sanitizeFrameOrigins, errors, warnings); + const httpOrigins = originList(m, 'httpOrigins', sanitizeHttpOrigins, errors, warnings); + const apiPostPaths = originList(m, 'apiPostPaths', sanitizeApiPostPaths, errors, warnings); + + if (errors.length > 0) return { manifest: null, errors, warnings }; + + const manifest: ValidatedPluginManifest = { + id, name, version, author, description, type, permissions, entrypoint, + ...(tier ? { tier } : {}), + ...(minAppVersion ? { minAppVersion } : {}), + ...(settingsSchema && Object.keys(settingsSchema).length ? { settingsSchema } : {}), + ...(configSchema && Object.keys(configSchema).length ? { configSchema } : {}), + ...(locales && Object.keys(locales).length ? { locales } : {}), + ...(frameOrigins ? { frameOrigins } : {}), + ...(httpOrigins ? { httpOrigins } : {}), + ...(apiPostPaths ? { apiPostPaths } : {}), + }; + return { manifest, errors, warnings }; +} + +// ─── Plugin code checks ────────────────────────────────────── + +const SUSPICIOUS_JS_PATTERNS = [ + { pattern: /\beval\s*\(/, label: 'eval()' }, + { pattern: /\bnew\s+Function\s*\(/, label: 'new Function()' }, + { pattern: /document\.cookie/, label: 'document.cookie' }, + { pattern: /document\.write/, label: 'document.write' }, + { pattern: /innerHTML\s*=/, label: 'innerHTML assignment' }, +]; + +/** Patterns worth a reviewer's second look. Not a security boundary: the sandbox is. */ +export function suspiciousCode(code: string): string[] { + return SUSPICIOUS_JS_PATTERNS.filter(({ pattern }) => pattern.test(code)).map(({ label }) => label); +} + +export interface PluginPackage { + manifest: ValidatedPluginManifest; + code: string; + warnings: string[]; + /** The opened ZIP, for callers that read more files (preview image). */ + pkg: OpenedPackage; +} + +/** + * Open, validate and read a plugin package. Throws ExtensionPackageError + * with every problem found when it is not installable. + */ +export async function readPluginPackage( + data: ArrayBuffer | Uint8Array, + opts: { appVersion?: string; expectedId?: string } = {}, +): Promise { + const pkg = await openPackage(data, 'plugin'); + const raw = await readManifest(pkg); + const check = checkPluginManifest(raw, opts); + const errors = [...check.errors]; + const bad = disallowedPluginFiles(pkg); + if (bad.length > 0) errors.push(`Disallowed file types: ${bad.join(', ')}`); + if (check.manifest && opts.expectedId !== undefined && check.manifest.id !== opts.expectedId) { + errors.push(`Package id "${check.manifest.id}" does not match "${opts.expectedId}"`); + } + if (errors.length > 0 || !check.manifest) throw new ExtensionPackageError(errors.join('; ')); + const code = await readText(pkg, check.manifest.entrypoint); + if (code === null) throw new ExtensionPackageError(`Missing entrypoint file: ${check.manifest.entrypoint}`); + const warnings = [...check.warnings, ...suspiciousCode(code).map((l) => `Contains ${l} - review for security`)]; + return { manifest: check.manifest, code, warnings, pkg }; +} diff --git a/lib/plugin-validator.ts b/lib/plugin-validator.ts index c37210f58..83bb7d06c 100644 --- a/lib/plugin-validator.ts +++ b/lib/plugin-validator.ts @@ -1,18 +1,21 @@ // Plugin/Theme ZIP upload validation, extraction, and manifest parsing -import JSZip from 'jszip'; import { type ThemeManifest, type PluginManifest, - type PluginType, - ALL_PERMISSIONS, - MAX_PLUGIN_SIZE, - MAX_THEME_SIZE, MAX_THEME_SKIN_BYTES, - ALLOWED_PLUGIN_FILES, } from './plugin-types'; import { sanitizeThemeCSS, sanitizeSkinCSS } from './theme-loader'; import { compileAdvancedTheme, isAdvancedManifest } from './theme-compiler'; +import { + ExtensionPackageError, + openPackage, + readEntry, + readManifest, + readPluginPackage, + readText, + type OpenedPackage, +} from './extension-package'; export interface ValidationResult { valid: boolean; @@ -101,83 +104,8 @@ function validateThemeManifest(manifest: Record): { result: The }; } -function validatePluginManifest(manifest: Record): { result: PluginManifest | null; errors: string[] } { - const errors = validateBaseManifest(manifest); - - const validTypes: PluginType[] = ['ui-extension', 'sidebar-app', 'hook']; - if (!validTypes.includes(manifest.type as PluginType)) { - errors.push(`Invalid type "${manifest.type}". Must be one of: ${validTypes.join(', ')}`); - } - - if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') { - errors.push('Missing or invalid "entrypoint"'); - } - - if (manifest.permissions && Array.isArray(manifest.permissions)) { - const validPerms = new Set(ALL_PERMISSIONS as readonly string[]); - const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p)); - if (unknown.length > 0) { - errors.push(`Unknown permissions: ${unknown.join(', ')}`); - } - } - - if (errors.length > 0) return { result: null, errors }; - - return { - result: { - ...(manifest as unknown as PluginManifest), - permissions: (manifest.permissions as string[]) || [], - }, - errors: [], - }; -} - -// ─── JS Security Checks ───────────────────────────────────── - -const SUSPICIOUS_JS_PATTERNS = [ - { pattern: /\beval\s*\(/g, label: 'eval()' }, - { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' }, - { pattern: /document\.cookie/g, label: 'document.cookie' }, - { pattern: /document\.write/g, label: 'document.write' }, - { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' }, -]; - -function checkJSSecurity(code: string): string[] { - const warnings: string[] = []; - for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) { - if (pattern.test(code)) { - warnings.push(`Contains ${label} - review for security`); - } - pattern.lastIndex = 0; // reset regex - } - return warnings; -} - // ─── ZIP Extraction ────────────────────────────────────────── -function getExtension(filename: string): string { - const dot = filename.lastIndexOf('.'); - return dot >= 0 ? filename.slice(dot).toLowerCase() : ''; -} - -/** - * Find the root of the ZIP contents. - * Some ZIPs have all files inside a single top-level folder. - */ -function findZipRoot(zip: JSZip): string { - const entries = Object.keys(zip.files); - // Check if all entries share a common top-level directory - const topDirs = new Set(entries.map(e => e.split('/')[0])); - if (topDirs.size === 1) { - const dir = [...topDirs][0]; - // Verify it's actually a directory (has entries inside it) - if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) { - return dir + '/'; - } - } - return ''; -} - /** * Extract and validate a theme ZIP file. */ @@ -185,38 +113,14 @@ export async function extractTheme(file: File): Promise { const errors: string[] = []; const warnings: string[] = []; - // Size check - if (file.size > MAX_THEME_SIZE) { - return { - valid: false, - errors: [`Theme ZIP exceeds ${Math.round(MAX_THEME_SIZE / (1024 * 1024))} MB size limit`], - warnings: [], - manifest: null, css: '', skin: null, preview: null, - }; - } - - let zip: JSZip; - try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - - const root = findZipRoot(zip); - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return { valid: false, errors: ['Missing manifest.json'], warnings: [], manifest: null, css: '', skin: null, preview: null }; - } - + let pkg: OpenedPackage; let manifestData: Record; try { - const raw = await manifestFile.async('string'); - manifestData = JSON.parse(raw); - } catch { - return { valid: false, errors: ['Invalid manifest.json (not valid JSON)'], warnings: [], manifest: null, css: '', skin: null, preview: null }; + pkg = await openPackage(await file.arrayBuffer(), 'theme'); + manifestData = await readManifest(pkg); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; + return { valid: false, errors: [message], warnings: [], manifest: null, css: '', skin: null, preview: null }; } const { result: manifest, errors: manifestErrors } = validateThemeManifest(manifestData); @@ -228,12 +132,20 @@ export async function extractTheme(file: File): Promise { // Read theme.css - required for v1 themes, optional when the manifest // declares Theme API v2 fields (tokens/extends/derive/density/radii/typography), // since the compiler can produce CSS purely from the manifest. - const cssFile = zip.file(root + 'theme.css'); const isAdvanced = isAdvancedManifest(manifest); + let cssText: string | null; + let skinText: string | null; + try { + cssText = await readText(pkg, 'theme.css'); + skinText = await readText(pkg, 'skin.css', MAX_THEME_SKIN_BYTES * 4); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Could not read theme files'; + return { valid: false, errors: [message], warnings, manifest, css: '', skin: null, preview: null }; + } let userCSS = ''; - if (cssFile) { - userCSS = await cssFile.async('string'); + if (cssText !== null) { + userCSS = cssText; // Sanitize instead of rejecting, and always: the sanitizer is also what // drops rules outside :root and .dark. const sanitized = sanitizeThemeCSS(userCSS); @@ -265,14 +177,11 @@ export async function extractTheme(file: File): Promise { // Read preview image if present let preview: string | null = null; if (manifest.preview) { - const previewFile = zip.file(root + manifest.preview); - if (previewFile) { - try { - const blob = await previewFile.async('blob'); - preview = await blobToDataUri(blob); - } catch { - warnings.push('Could not read preview image'); - } + try { + const bytes = await readEntry(pkg, manifest.preview, 512 * 1024); + if (bytes) preview = await blobToDataUri(new Blob([bytes as BlobPart], { type: previewType(manifest.preview) })); + } catch { + warnings.push('Could not read preview image'); } } @@ -280,12 +189,11 @@ export async function extractTheme(file: File): Promise { // component selectors and bypass the strict :root/.dark selector check - // they still go through the dangerous-pattern sanitizer. let skin: string | null = null; - const skinFile = zip.file(root + 'skin.css'); - if (skinFile) { + if (skinText !== null) { if (!isAdvanced) { warnings.push('skin.css ignored - only Theme API v2 manifests can ship a skin'); } else { - const rawSkin = await skinFile.async('string'); + const rawSkin = skinText; if (rawSkin.length > MAX_THEME_SKIN_BYTES) { warnings.push(`skin.css exceeds ${Math.round(MAX_THEME_SKIN_BYTES / 1024)} KB and was dropped`); } else { @@ -308,87 +216,45 @@ export async function extractTheme(file: File): Promise { } /** - * Extract and validate a plugin ZIP file. + * Extract and validate a plugin ZIP file. The checks are the ones every + * server install path runs too (see extension-package.ts). */ export async function extractPlugin(file: File): Promise { - const errors: string[] = []; - const warnings: string[] = []; - - if (file.size > MAX_PLUGIN_SIZE) { - return { valid: false, errors: ['Plugin ZIP exceeds 5 MB size limit'], warnings: [], manifest: null, code: '', preview: null }; - } - - let zip: JSZip; + let result: Awaited>; try { - const buffer = await file.arrayBuffer(); - zip = await JSZip.loadAsync(buffer); - } catch { - return { valid: false, errors: ['Invalid ZIP file'], warnings: [], manifest: null, code: '', preview: null }; + result = await readPluginPackage(await file.arrayBuffer(), { + appVersion: process.env.NEXT_PUBLIC_APP_VERSION, + }); + } catch (err) { + const message = err instanceof ExtensionPackageError ? err.message : 'Invalid ZIP file'; + return { valid: false, errors: message.split('; '), warnings: [], manifest: null, code: '', preview: null }; } + const warnings = [...result.warnings]; - const root = findZipRoot(zip); - - // Check for disallowed file extensions - for (const [path, entry] of Object.entries(zip.files)) { - if (entry.dir) continue; - const ext = getExtension(path); - if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) { - errors.push(`Disallowed file type: ${path} (${ext})`); - } - } - - // Read manifest - const manifestFile = zip.file(root + 'manifest.json'); - if (!manifestFile) { - return { valid: false, errors: ['Missing manifest.json', ...errors], warnings, manifest: null, code: '', preview: null }; - } - - let manifestData: Record; - try { - const raw = await manifestFile.async('string'); - manifestData = JSON.parse(raw); - } catch { - return { valid: false, errors: ['Invalid manifest.json (not valid JSON)', ...errors], warnings, manifest: null, code: '', preview: null }; - } - - const { result: manifest, errors: manifestErrors } = validatePluginManifest(manifestData); - errors.push(...manifestErrors); - if (!manifest) { - return { valid: false, errors, warnings, manifest: null, code: '', preview: null }; - } - - // Read entrypoint - const entryFile = zip.file(root + manifest.entrypoint); - if (!entryFile) { - errors.push(`Missing entrypoint file: ${manifest.entrypoint}`); - return { valid: false, errors, warnings, manifest, code: '', preview: null }; - } - - const code = await entryFile.async('string'); - - // JS security checks - warnings.push(...checkJSSecurity(code)); - - // Read preview if present let preview: string | null = null; - const previewFile = zip.file(root + 'preview.png') || zip.file(root + 'preview.svg'); - if (previewFile) { + const previewName = result.pkg.zip.file(result.pkg.root + 'preview.png') ? 'preview.png' + : result.pkg.zip.file(result.pkg.root + 'preview.svg') ? 'preview.svg' : null; + if (previewName) { try { - const blob = await previewFile.async('blob'); - preview = await blobToDataUri(blob); + const bytes = await readEntry(result.pkg, previewName, 512 * 1024); + if (bytes) { + const type = previewName.endsWith('.svg') ? 'image/svg+xml' : 'image/png'; + preview = await blobToDataUri(new Blob([bytes as BlobPart], { type })); + } } catch { warnings.push('Could not read preview image'); } } - return { - valid: errors.length === 0, - errors, - warnings, - manifest, - code, - preview, - }; + return { valid: true, errors: [], warnings, manifest: result.manifest, code: result.code, preview }; +} + +function previewType(name: string): string { + const lower = name.toLowerCase(); + if (lower.endsWith('.svg')) return 'image/svg+xml'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.webp')) return 'image/webp'; + return 'image/png'; } function blobToDataUri(blob: Blob): Promise { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 078c36491..2686a8b7a 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -88,6 +88,7 @@ export const usePluginStore = create()( adminApproved: false, // Requires admin approval before it can be enabled settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, + ...(manifest.locales ? { locales: manifest.locales } : {}), ...(bundleHash ? { bundleHash } : {}), ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 ? { httpOrigins: manifest.httpOrigins }