fix: enhance security by blocking plugins with dangerous JS patterns and enforcing strict session secret length
This commit is contained in:
@@ -196,6 +196,26 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const code = await jsFile.async('string');
|
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
|
// Validate permissions
|
||||||
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
|
||||||
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
|
||||||
|
|||||||
@@ -139,12 +139,18 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
const code = await entryFile.async('string');
|
const code = await entryFile.async('string');
|
||||||
|
|
||||||
// Security warnings (logged but not blocking for admin)
|
// Security: block plugins containing dangerous JS patterns
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
|
||||||
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
if (pattern.test(code)) warnings.push(`Contains ${label}`);
|
||||||
pattern.lastIndex = 0;
|
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 now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -165,9 +171,9 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
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) {
|
} catch (error) {
|
||||||
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|||||||
@@ -73,13 +73,17 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT — retrieve full credentials (including password) for session restoration.
|
* 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) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
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');
|
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 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
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();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,17 @@ const ALGORITHM = 'aes-256-gcm';
|
|||||||
const IV_LENGTH = 12;
|
const IV_LENGTH = 12;
|
||||||
const TAG_LENGTH = 16;
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
const MIN_SECRET_LENGTH = 32;
|
||||||
|
|
||||||
function getKey(): Buffer {
|
function getKey(): Buffer {
|
||||||
const secret = process.env.SESSION_SECRET;
|
const secret = process.env.SESSION_SECRET;
|
||||||
if (!secret) throw new Error('SESSION_SECRET not configured');
|
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();
|
return createHash('sha256').update(secret).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ export const EMAIL_SANITIZE_CONFIG = {
|
|||||||
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
ADD_ATTR: ['target', 'rel', 'style', 'class', 'width', 'height', 'align', 'valign', 'bgcolor', 'color'],
|
||||||
ALLOW_DATA_ATTR: false,
|
ALLOW_DATA_ATTR: false,
|
||||||
FORCE_BODY: true,
|
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
|
// 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: [
|
FORBID_TAGS: [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
'input', 'button', 'meta', 'link', 'base',
|
'input', 'button', 'meta', 'link', 'base',
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ export const ALLOWED_PLUGIN_FILES = new Set([
|
|||||||
export const DISALLOWED_CSS_PATTERNS = [
|
export const DISALLOWED_CSS_PATTERNS = [
|
||||||
/@import\b/i,
|
/@import\b/i,
|
||||||
/url\s*\(\s*['"]?https?:/i,
|
/url\s*\(\s*['"]?https?:/i,
|
||||||
|
/url\s*\(\s*['"]?data:/i,
|
||||||
/expression\s*\(/i,
|
/expression\s*\(/i,
|
||||||
/javascript\s*:/i,
|
/javascript\s*:/i,
|
||||||
/-moz-binding/i,
|
/-moz-binding/i,
|
||||||
|
|||||||
Reference in New Issue
Block a user