fix: check plugin and theme packages the same way on every install path

The Settings upload, the admin upload, the marketplace install and the
marketplace preview each parsed the ZIP and manifest with their own copy
of the code, and the copies had drifted. A `permissions` string skipped
validation and was then matched by substring, a small ZIP could inflate
to hundreds of megabytes on the server, the Settings upload dropped
`locales` and took origin lists unchecked, and settings schemas were
never checked at all.

All of them now go through `lib/extension-package.ts`:
- every file is inflated through a byte cap, with a limit on entries
- one manifest check: types, permissions, entrypoint, version,
  minAppVersion, settingsSchema, configSchema, locales and origin lists
- only checked fields are stored
This commit is contained in:
Linus Rath
2026-10-14 01:43:43 +02:00
parent 0dd04ed21d
commit e57b7a2c18
14 changed files with 1139 additions and 715 deletions
+22 -56
View File
@@ -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<string> {
@@ -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,
};
}
}
}
@@ -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
+119 -215
View File
@@ -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<string, unknown>;
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<string, unknown>;
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 });
@@ -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<string, unknown>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
getPolicy: vi.fn(() => policy),
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
},
}));
const registry = new Map<string, Record<string, unknown>>();
const removedConfig: string[] = [];
vi.mock('@/lib/admin/plugin-registry', () => ({
savePlugin: vi.fn(async (p: Record<string, unknown>) => { 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<typeof import('@/lib/admin/csp-frame-origins')>()),
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<string, string | Uint8Array>) {
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);
});
});
+35 -133
View File
@@ -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<string, unknown>;
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 });
+18 -1
View File
@@ -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/);
});
});
+15 -39
View File
@@ -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<string, unknown>;
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[] = [];
+135
View File
@@ -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<string, string | Uint8Array>): Promise<Uint8Array> {
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<string, string> = { '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);
});
});
+22
View File
@@ -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' } });
});
});
+8 -77
View File
@@ -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<string>();
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<string>();
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
+81
View File
@@ -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<string>();
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<string>();
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;
}
+508
View File
@@ -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<OpenedPackage> {
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<Uint8Array | null> {
const entry = pkg.zip.file(pkg.root + name);
if (!entry || entry.dir) return null;
return new Promise<Uint8Array>((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<string | null> {
const bytes = await readEntry(pkg, name, maxBytes);
return bytes === null ? null : new TextDecoder('utf-8').decode(bytes);
}
export async function readManifest(pkg: OpenedPackage): Promise<Record<string, unknown>> {
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<string, unknown>;
}
/** 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<T> {
manifest: T | null;
errors: string[];
warnings: string[];
}
type InstallablePluginType = Exclude<PluginType, 'theme'>;
const PLUGIN_TYPES: InstallablePluginType[] = ['ui-extension', 'sidebar-app', 'hook'];
const KNOWN_PERMISSIONS = new Set<string>(ALL_PERMISSIONS);
function requireString(m: Record<string, unknown>, 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<string, unknown>, 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<string, SettingFieldSchema> | 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<string, SettingFieldSchema> = {};
for (const [key, field] of Object.entries(raw as Record<string, unknown>)) {
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<string, unknown>;
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<string, ConfigFieldSchema> | 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<string, ConfigFieldSchema> = {};
for (const [key, field] of Object.entries(raw as Record<string, unknown>)) {
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<string, unknown> | 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<string, Record<string, string>> | 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<string, unknown>);
if (entries.length > MAX_LOCALES) {
errors.push(`"locales" has more than ${MAX_LOCALES} languages`);
return undefined;
}
const out: Record<string, Record<string, string>> = {};
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<string, unknown>);
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<string, string>;
}
return out;
}
function originList(
m: Record<string, unknown>,
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<string, ConfigFieldSchema>;
};
/**
* 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<string, unknown>, opts: { appVersion?: string } = {}): ManifestCheck<ValidatedPluginManifest> {
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<PluginPackage> {
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 };
}
+60 -194
View File
@@ -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<string, unknown>): { result: The
};
}
function validatePluginManifest(manifest: Record<string, unknown>): { 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<ThemeExtractionResult> {
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<string, unknown>;
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<ThemeExtractionResult> {
// 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<ThemeExtractionResult> {
// 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<ThemeExtractionResult> {
// 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<ThemeExtractionResult> {
}
/**
* 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<PluginExtractionResult> {
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<ReturnType<typeof readPluginPackage>>;
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<string, unknown>;
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<string> {
+1
View File
@@ -88,6 +88,7 @@ export const usePluginStore = create<PluginStoreState>()(
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 }