Security fixes: Remove PAT, add idempotency, fix admin auth
- Remove exposed GitHub PAT from git remote URL - Remove admin password plaintext fallback (bcrypt only) - Add webhook idempotency protection to prevent duplicate payments - Fix webhook error handling to return 500 on errors (enables retry) - Upgrade archiver to v7 to fix npm vulnerabilities - Add production environment validation for critical secrets - Add comprehensive security review documentation
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^6.0.1",
|
||||
"archiver": "^7.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pdfkit": "^0.17.2",
|
||||
|
||||
124
chat/server.js
124
chat/server.js
@@ -185,6 +185,8 @@ const FEATURE_REQUESTS_FILE = path.join(STATE_DIR, 'feature-requests.json');
|
||||
const CONTACT_MESSAGES_FILE = path.join(STATE_DIR, 'contact-messages.json');
|
||||
const INVOICES_FILE = path.join(STATE_DIR, 'invoices.json');
|
||||
const INVOICES_DIR = path.join(STATE_DIR, 'invoices');
|
||||
const PROCESSED_WEBHOOKS_FILE = path.join(STATE_DIR, 'processed-webhooks.json');
|
||||
const PROCESSED_WEBHOOKS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
// One-off top-up discounts (Business 2.5%, Enterprise 5%; boost add-ons keep higher 10%/25% rates)
|
||||
const BUSINESS_TOPUP_DISCOUNT = 0.025;
|
||||
const ENTERPRISE_TOPUP_DISCOUNT = 0.05;
|
||||
@@ -693,6 +695,10 @@ let lastMemoryCleanup = 0;
|
||||
let memoryCleanupTimer = null;
|
||||
const recentlyCleanedSessions = new Set(); // Track recently cleaned sessions to avoid redundant work
|
||||
|
||||
// Processed webhooks for idempotency protection (prevents duplicate payment processing)
|
||||
const processedWebhookEvents = new Map(); // eventId -> { processedAt, type }
|
||||
let processedWebhooksPersistTimer = null;
|
||||
|
||||
// Track spawned child processes for proper cleanup
|
||||
const childProcesses = new Map(); // processId -> { pid, startTime, sessionId, messageId }
|
||||
|
||||
@@ -1087,6 +1093,72 @@ function stopMemoryCleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhook Idempotency Protection
|
||||
// ============================================================================
|
||||
|
||||
async function loadProcessedWebhooks() {
|
||||
try {
|
||||
await ensureStateFile();
|
||||
const raw = await fs.readFile(PROCESSED_WEBHOOKS_FILE, 'utf8').catch(() => null);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const now = Date.now();
|
||||
// Clean up old entries on load
|
||||
for (const [eventId, data] of Object.entries(parsed)) {
|
||||
if (data && data.processedAt && (now - data.processedAt) < PROCESSED_WEBHOOKS_MAX_AGE_MS) {
|
||||
processedWebhookEvents.set(eventId, data);
|
||||
}
|
||||
}
|
||||
log('Loaded processed webhooks', { count: processedWebhookEvents.size });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log('Failed to load processed webhooks', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function persistProcessedWebhooks() {
|
||||
try {
|
||||
await ensureStateFile();
|
||||
const now = Date.now();
|
||||
const obj = {};
|
||||
// Only persist recent entries
|
||||
for (const [eventId, data] of processedWebhookEvents.entries()) {
|
||||
if (data && data.processedAt && (now - data.processedAt) < PROCESSED_WEBHOOKS_MAX_AGE_MS) {
|
||||
obj[eventId] = data;
|
||||
}
|
||||
}
|
||||
await safeWriteFile(PROCESSED_WEBHOOKS_FILE, JSON.stringify(obj, null, 2));
|
||||
} catch (error) {
|
||||
log('Failed to persist processed webhooks', { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function isWebhookProcessed(eventId) {
|
||||
if (!eventId) return false;
|
||||
return processedWebhookEvents.has(eventId);
|
||||
}
|
||||
|
||||
async function markWebhookProcessed(eventId, eventType) {
|
||||
if (!eventId) return;
|
||||
processedWebhookEvents.set(eventId, {
|
||||
processedAt: Date.now(),
|
||||
type: eventType || 'unknown'
|
||||
});
|
||||
|
||||
// Debounce persistence
|
||||
if (processedWebhooksPersistTimer) {
|
||||
clearTimeout(processedWebhooksPersistTimer);
|
||||
}
|
||||
processedWebhooksPersistTimer = setTimeout(() => {
|
||||
persistProcessedWebhooks().catch(err => {
|
||||
log('Failed to persist processed webhooks (debounced)', { error: String(err) });
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a child process for tracking
|
||||
* @param {string} processId - Unique process identifier
|
||||
@@ -14385,6 +14457,13 @@ async function handleDodoWebhook(req, res) {
|
||||
const event = JSON.parse(rawBody);
|
||||
log('Dodo webhook received', { type: event.type, id: event.id });
|
||||
|
||||
// Idempotency check - prevent duplicate processing
|
||||
const eventId = event.id || event.data?.id;
|
||||
if (eventId && isWebhookProcessed(eventId)) {
|
||||
log('Dodo webhook already processed, skipping', { eventId, type: event.type });
|
||||
return sendJson(res, 200, { received: true, duplicate: true });
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment_succeeded':
|
||||
case 'subscription_payment_succeeded':
|
||||
@@ -14454,10 +14533,18 @@ async function handleDodoWebhook(req, res) {
|
||||
log('Unhandled Dodo webhook event', { type: event.type });
|
||||
}
|
||||
|
||||
// Mark as processed for idempotency
|
||||
const eventId = event.id || event.data?.id;
|
||||
if (eventId) {
|
||||
await markWebhookProcessed(eventId, event.type);
|
||||
}
|
||||
|
||||
sendJson(res, 200, { received: true });
|
||||
} catch (error) {
|
||||
log('Dodo webhook error', { error: String(error), stack: error.stack });
|
||||
sendJson(res, 200, { received: true });
|
||||
// Return 500 to trigger webhook retry from payment provider
|
||||
// This ensures failed payments aren't silently lost
|
||||
sendJson(res, 500, { error: 'Webhook processing failed', received: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15792,15 +15879,13 @@ async function handleAdminLogin(req, res) {
|
||||
return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||||
}
|
||||
|
||||
// Validate password using bcrypt
|
||||
let passwordValid = false;
|
||||
if (adminPasswordHash) {
|
||||
passwordValid = await bcrypt.compare(pass, adminPasswordHash);
|
||||
} else {
|
||||
// Fallback to plaintext comparison if hashing failed at startup
|
||||
passwordValid = pass === ADMIN_PASSWORD.trim();
|
||||
// Validate password using bcrypt - no plaintext fallback for security
|
||||
if (!adminPasswordHash) {
|
||||
log('admin login failed: bcrypt hash not initialized', { ip: clientIp });
|
||||
return sendJson(res, 500, { error: 'Authentication system not ready. Please try again.' });
|
||||
}
|
||||
|
||||
|
||||
const passwordValid = await bcrypt.compare(pass, adminPasswordHash);
|
||||
if (!passwordValid) {
|
||||
log('failed admin login', { user: userNormalized, ip: clientIp, reason: 'invalid_password' });
|
||||
return sendJson(res, 401, { error: 'Incorrect credentials' });
|
||||
@@ -19272,6 +19357,26 @@ async function routeInternal(req, res, url, pathname) {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// Production environment validation
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const criticalEnvVars = [];
|
||||
|
||||
if (isProduction) {
|
||||
if (!process.env.USER_SESSION_SECRET && !process.env.SESSION_SECRET) {
|
||||
criticalEnvVars.push('USER_SESSION_SECRET or SESSION_SECRET');
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
log('Uncaught Exception, saving state before exit', { error: String(error), stack: error.stack });
|
||||
try {
|
||||
@@ -19312,6 +19417,7 @@ async function bootstrap() {
|
||||
await loadAffiliatesDb();
|
||||
await loadWithdrawalsDb();
|
||||
await loadTrackingData();
|
||||
await loadProcessedWebhooks();
|
||||
await loadFeatureRequestsDb();
|
||||
contactMessagesDb = await loadContactMessagesDb();
|
||||
await ensureAssetsDir();
|
||||
|
||||
Reference in New Issue
Block a user