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:
Developer
2026-02-20 21:51:45 +00:00
parent a831f331cd
commit 2a7971eda1
3 changed files with 630 additions and 10 deletions

View File

@@ -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",

View File

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