fix security
Some checks are pending
Build Android App (Capacitor) / Build Android APK (push) Waiting to run

This commit is contained in:
Developer
2026-02-21 10:07:02 +00:00
parent be04ff9731
commit 98c3b5f040
17 changed files with 5692 additions and 20 deletions

View File

@@ -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 });