fix security
Some checks are pending
Build Android App (Capacitor) / Build Android APK (push) Waiting to run
Some checks are pending
Build Android App (Capacitor) / Build Android APK (push) Waiting to run
This commit is contained in:
162
chat/server.js
162
chat/server.js
@@ -387,13 +387,36 @@ const OPENCODE_MAX_CONCURRENCY = Number(process.env.OPENCODE_MAX_CONCURRENCY ||
|
||||
// User authentication configuration
|
||||
const USERS_DB_FILE = path.join(STATE_DIR, 'users.json');
|
||||
const USER_SESSIONS_FILE = path.join(STATE_DIR, 'user-sessions.json');
|
||||
const USER_SESSION_SECRET = process.env.USER_SESSION_SECRET || process.env.SESSION_SECRET || (() => {
|
||||
// Generate a secure random session secret for development
|
||||
// In production, this should be set via environment variable
|
||||
const USER_SESSION_SECRET = (() => {
|
||||
if (process.env.USER_SESSION_SECRET) return process.env.USER_SESSION_SECRET;
|
||||
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
||||
|
||||
const secretsFile = path.join(STATE_DIR, 'generated-secrets.json');
|
||||
try {
|
||||
if (fsSync.existsSync(secretsFile)) {
|
||||
const existing = JSON.parse(fsSync.readFileSync(secretsFile, 'utf8'));
|
||||
if (existing.userSessionSecret) {
|
||||
console.log('✅ Using persisted session secret from', secretsFile);
|
||||
return existing.userSessionSecret;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read persisted secrets, generating new ones:', err.message);
|
||||
}
|
||||
|
||||
const generatedSecret = randomBytes(32).toString('hex');
|
||||
console.warn('⚠️ WARNING: No USER_SESSION_SECRET or SESSION_SECRET found. Generated a random secret for this session.');
|
||||
console.warn('⚠️ For production use, set USER_SESSION_SECRET environment variable to a secure random value.');
|
||||
console.warn('⚠️ Generate one with: openssl rand -hex 32');
|
||||
console.warn('⚠️ WARNING: No USER_SESSION_SECRET or SESSION_SECRET found. Generated a random secret.');
|
||||
console.warn('⚠️ For production use, set USER_SESSION_SECRET environment variable.');
|
||||
console.warn('⚠️ Secret persisted to:', secretsFile);
|
||||
|
||||
try {
|
||||
fsSync.mkdirSync(STATE_DIR, { recursive: true });
|
||||
const secrets = { userSessionSecret: generatedSecret, generatedAt: new Date().toISOString() };
|
||||
fsSync.writeFileSync(secretsFile, JSON.stringify(secrets, null, 2));
|
||||
} catch (writeErr) {
|
||||
console.error('Failed to persist generated secret:', writeErr.message);
|
||||
}
|
||||
|
||||
return generatedSecret;
|
||||
})();
|
||||
const USER_COOKIE_NAME = 'user_session';
|
||||
@@ -742,6 +765,9 @@ function triggerMemoryCleanup(reason = 'manual') {
|
||||
// Clean up orphaned processes
|
||||
cleanupOrphanedProcesses();
|
||||
|
||||
// Clean up stale pending payments
|
||||
cleanupStalePendingPayments();
|
||||
|
||||
// Truncate large message outputs (less frequently)
|
||||
if (now % 300000 < 60000) { // Every 5 minutes
|
||||
truncateLargeOutputs();
|
||||
@@ -1104,6 +1130,58 @@ function stopMemoryCleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
const PENDING_PAYMENT_MAX_AGE_MS = 48 * 60 * 60 * 1000; // 48 hours
|
||||
|
||||
function cleanupStalePendingPayments() {
|
||||
const now = Date.now();
|
||||
let cleaned = { topups: 0, payg: 0, subscriptions: 0 };
|
||||
|
||||
for (const [key, entry] of Object.entries(pendingTopups || {})) {
|
||||
if (entry && entry.createdAt) {
|
||||
const age = now - new Date(entry.createdAt).getTime();
|
||||
if (age > PENDING_PAYMENT_MAX_AGE_MS) {
|
||||
delete pendingTopups[key];
|
||||
cleaned.topups++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(pendingPayg || {})) {
|
||||
if (entry && entry.createdAt) {
|
||||
const age = now - new Date(entry.createdAt).getTime();
|
||||
if (age > PENDING_PAYMENT_MAX_AGE_MS) {
|
||||
delete pendingPayg[key];
|
||||
cleaned.payg++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, entry] of Object.entries(pendingSubscriptions || {})) {
|
||||
if (entry && entry.createdAt) {
|
||||
const age = now - new Date(entry.createdAt).getTime();
|
||||
if (age > PENDING_PAYMENT_MAX_AGE_MS) {
|
||||
delete pendingSubscriptions[key];
|
||||
cleaned.subscriptions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = cleaned.topups + cleaned.payg + cleaned.subscriptions;
|
||||
if (total > 0) {
|
||||
log('Cleaned up stale pending payment sessions', cleaned);
|
||||
Promise.all([
|
||||
persistTopupSessions(),
|
||||
persistPendingTopups(),
|
||||
persistPaygSessions(),
|
||||
persistPendingPayg(),
|
||||
persistPendingSubscriptions(),
|
||||
persistProcessedSubscriptions()
|
||||
]).catch(err => log('Failed to persist after payment cleanup', { error: String(err) }));
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhook Idempotency Protection
|
||||
// ============================================================================
|
||||
@@ -8832,9 +8910,19 @@ async function extractZipToWorkspace(buffer, workspaceDir) {
|
||||
|
||||
const decoded = (() => { try { return decodeURIComponent(entryPath); } catch (_) { return entryPath; } })();
|
||||
const normalized = path.normalize(decoded);
|
||||
|
||||
// Path traversal protection
|
||||
if (!entryPath || normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) continue;
|
||||
if (path.isAbsolute(normalized)) continue;
|
||||
if (BLOCKED_PATH_PATTERN.test(normalized)) continue;
|
||||
|
||||
// Block potentially dangerous file types
|
||||
const lowerName = normalized.toLowerCase();
|
||||
if (lowerName.endsWith('.exe') || lowerName.endsWith('.bat') || lowerName.endsWith('.cmd') ||
|
||||
lowerName.endsWith('.sh') || lowerName.endsWith('.ps1') || lowerName.endsWith('.vbs')) {
|
||||
log('Skipping potentially dangerous file in zip', { entry: normalized });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetPath = path.join(workspaceDir, normalized);
|
||||
const resolved = path.resolve(targetPath);
|
||||
@@ -8849,6 +8937,16 @@ async function extractZipToWorkspace(buffer, workspaceDir) {
|
||||
const data = entry.getData();
|
||||
await fs.writeFile(resolved, data);
|
||||
fileCount += 1;
|
||||
|
||||
// Check for symlinks in extracted files (security)
|
||||
try {
|
||||
const stat = await fs.lstat(resolved);
|
||||
if (stat.isSymbolicLink()) {
|
||||
log('Removing symbolic link from extracted zip', { path: resolved });
|
||||
await fs.unlink(resolved);
|
||||
continue;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (fileCount === 0) {
|
||||
@@ -8860,9 +8958,12 @@ async function extractZipToWorkspace(buffer, workspaceDir) {
|
||||
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
// CORS headers are already set in route(), but ensure they're preserved
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': PUBLIC_BASE_URL || '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-User-Id, X-CSRF-Token',
|
||||
'Access-Control-Allow-Credentials': 'true'
|
||||
};
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(JSON.stringify(payload));
|
||||
@@ -15161,7 +15262,9 @@ async function handleDodoWebhook(req, res) {
|
||||
|
||||
if (DODO_WEBHOOK_KEY && signature) {
|
||||
const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`;
|
||||
if (!require('crypto').timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
||||
const sigBuffer = Buffer.from(signature);
|
||||
const expectedBuffer = Buffer.from(expectedSignature);
|
||||
if (sigBuffer.length !== expectedBuffer.length || !require('crypto').timingSafeEqual(sigBuffer, expectedBuffer)) {
|
||||
log('Dodo webhook signature verification failed', { signature });
|
||||
return sendJson(res, 401, { error: 'Invalid signature' });
|
||||
}
|
||||
@@ -15250,7 +15353,6 @@ async function handleDodoWebhook(req, res) {
|
||||
}
|
||||
|
||||
// Mark as processed for idempotency
|
||||
const eventId = event.id || event.data?.id;
|
||||
if (eventId) {
|
||||
await markWebhookProcessed(eventId, event.type);
|
||||
}
|
||||
@@ -20568,10 +20670,18 @@ async function routeInternal(req, res, url, pathname) {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
console.log('');
|
||||
console.log('╔═══════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Plugin Compass - Starting Server ║');
|
||||
console.log('╚═══════════════════════════════════════════════════════════════╝');
|
||||
console.log('');
|
||||
|
||||
// Production environment validation
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const criticalEnvVars = [];
|
||||
const recommendedEnvVars = [];
|
||||
|
||||
// Critical: Required for production
|
||||
if (isProduction) {
|
||||
if (!process.env.USER_SESSION_SECRET && !process.env.SESSION_SECRET) {
|
||||
criticalEnvVars.push('USER_SESSION_SECRET or SESSION_SECRET');
|
||||
@@ -20579,14 +20689,34 @@ async function bootstrap() {
|
||||
if (!process.env.DODO_PAYMENTS_API_KEY && !process.env.DODO_API_KEY) {
|
||||
criticalEnvVars.push('DODO_PAYMENTS_API_KEY');
|
||||
}
|
||||
|
||||
if (criticalEnvVars.length > 0) {
|
||||
console.error('❌ CRITICAL: Missing required environment variables for production:');
|
||||
criticalEnvVars.forEach(v => console.error(` - ${v}`));
|
||||
console.error('Please set these environment variables before running in production.');
|
||||
process.exit(1);
|
||||
if (!process.env.DATABASE_ENCRYPTION_KEY) {
|
||||
criticalEnvVars.push('DATABASE_ENCRYPTION_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended warnings (not critical)
|
||||
if (!process.env.MAILPILOT_TOKEN) {
|
||||
recommendedEnvVars.push('MAILPILOT_TOKEN (emails will not be sent)');
|
||||
}
|
||||
if (!process.env.OPENROUTER_API_KEY && !process.env.OPENROUTER_API_TOKEN) {
|
||||
recommendedEnvVars.push('OPENROUTER_API_KEY (no AI provider configured)');
|
||||
}
|
||||
|
||||
if (criticalEnvVars.length > 0) {
|
||||
console.error('');
|
||||
console.error('❌ CRITICAL: Missing required environment variables for production:');
|
||||
criticalEnvVars.forEach(v => console.error(` - ${v}`));
|
||||
console.error('');
|
||||
console.error('Please set these environment variables before running in production.');
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (recommendedEnvVars.length > 0) {
|
||||
console.log('⚠️ Recommended environment variables not set:');
|
||||
recommendedEnvVars.forEach(v => console.log(` - ${v}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
log('Uncaught Exception, saving state before exit', { error: String(error), stack: error.stack });
|
||||
|
||||
Reference in New Issue
Block a user