diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 13abde0f..e57b5868 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -196,6 +196,26 @@ export async function POST(request: NextRequest) { 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[]); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index a253fb93..b1137346 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -139,12 +139,18 @@ export async function POST(request: NextRequest) { } const code = await entryFile.async('string'); - // Security warnings (logged but not blocking for admin) + // 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) { + return NextResponse.json( + { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` }, + { status: 400 }, + ); + } const now = new Date().toISOString(); const plugin: ServerPlugin = { @@ -165,9 +171,9 @@ export async function POST(request: NextRequest) { }; await savePlugin(plugin, code); - await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, warnings }, ip); + await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip); - return NextResponse.json({ plugin, warnings }); + return NextResponse.json({ plugin }); } 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/auth/session/route.ts b/app/api/auth/session/route.ts index 21635efd..40e13f17 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -73,13 +73,17 @@ export async function GET(request: NextRequest) { /** * PUT — retrieve full credentials (including password) for session restoration. - * Protected by Sec-Fetch-Site to ensure only same-origin browser requests succeed. + * Protected by multiple Sec-Fetch-* headers to ensure only same-origin + * browser fetch() requests succeed. Non-browser clients cannot forge these. */ export async function PUT(request: NextRequest) { try { - // Block non-browser and cross-origin requests + // Require all Sec-Fetch-* headers to match a same-origin fetch() call. + // Browsers set these automatically and they cannot be overridden by JS. const secFetchSite = request.headers.get('sec-fetch-site'); - if (secFetchSite !== 'same-origin') { + const secFetchMode = request.headers.get('sec-fetch-mode'); + const secFetchDest = request.headers.get('sec-fetch-dest'); + if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } diff --git a/lib/admin/session.ts b/lib/admin/session.ts index 0a69865d..3d91369f 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; +const MIN_SECRET_LENGTH = 32; + function getKey(): Buffer { const secret = process.env.SESSION_SECRET; if (!secret) throw new Error('SESSION_SECRET not configured'); + if (secret.length < MIN_SECRET_LENGTH) { + throw new Error( + `SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` + + `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` + ); + } return createHash('sha256').update(secret).digest(); } diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 6a3d689a..ca0506c9 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const TAG_LENGTH = 16; +const MIN_SECRET_LENGTH = 32; + function getKey(): Buffer { const secret = process.env.SESSION_SECRET; if (!secret) throw new Error('SESSION_SECRET not configured'); + if (secret.length < MIN_SECRET_LENGTH) { + throw new Error( + `SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` + + `Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` + ); + } return createHash('sha256').update(secret).digest(); } diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index 9eb67556..c458de89 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = { ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'], ALLOW_DATA_ATTR: false, FORCE_BODY: true, - // Allow blob: URIs so authenticated inline images (CID) are not stripped + // Allow blob: URIs so authenticated inline images (CID) are not stripped. + // data: is restricted to image/* MIME types to prevent SVG script injection. // eslint-disable-next-line no-useless-escape - ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|blob):|data:image\/|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, FORBID_TAGS: [ 'script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'meta', 'link', 'base', diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index d5536eeb..e53ee087 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -427,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([ export const DISALLOWED_CSS_PATTERNS = [ /@import\b/i, /url\s*\(\s*['"]?https?:/i, + /url\s*\(\s*['"]?data:/i, /expression\s*\(/i, /javascript\s*:/i, /-moz-binding/i,