From c52709da7de2c6c13e02d5a98f742ab9b5674cd4 Mon Sep 17 00:00:00 2001 From: southseact-3d Date: Sat, 21 Feb 2026 10:53:56 +0000 Subject: [PATCH] security --- chat/server.js | 385 ++++++++++++++++++++++++++++++------------------- 1 file changed, 240 insertions(+), 145 deletions(-) diff --git a/chat/server.js b/chat/server.js index 3bbc199..bfc265c 100644 --- a/chat/server.js +++ b/chat/server.js @@ -550,7 +550,8 @@ const AUTO_MODEL_TOKEN = 'auto'; const DEFAULT_PROVIDER_FALLBACK = 'opencode'; const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK, 'cohere', 'kilo']; const PROVIDER_PERSIST_DEBOUNCE_MS = 200; -const TOKEN_ESTIMATION_BUFFER = 400; +const TOKEN_ESTIMATION_BUFFER = 800; +const TOKEN_HISTORY_OVERHEAD = 200; const BOOST_PACK_SIZE = 500_000; const BOOST_BASE_PRICE = 15; const TOKEN_GRACE_RATIO = 0.05; @@ -1603,6 +1604,7 @@ async function gracefulShutdown(signal) { // Stop periodic tasks first stopAutoSave(); stopMemoryCleanup(); + stopPendingCleanup(); // Notify all active SSE connections about the restart for (const [messageId, streams] of activeStreams.entries()) { @@ -2112,10 +2114,19 @@ function validateCsrfToken(token, userId) { } // Security: Honeypot Detection +const HONEYPOT_FIELDS = ['website', 'url', 'homepage', 'web_address', 'site', 'link']; function checkHoneypot(body) { - return !!(body.website && body.website.length > 0); + if (!body || typeof body !== 'object') return false; + for (const field of HONEYPOT_FIELDS) { + if (body[field] && typeof body[field] === 'string' && body[field].length > 0) { + return true; + } + } + return false; } +const passwordResetAttempts = new Map(); + // Security: Prompt Injection Protection - Comprehensive function sanitizePromptInput(input, options = {}) { if (!input || typeof input !== 'string') return ''; @@ -2302,7 +2313,7 @@ function startAdminSession(res) { 'SameSite=Lax', `Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}`, ]; - if (process.env.COOKIE_SECURE === '0') parts.push('Secure'); + if (process.env.COOKIE_SECURE !== '0' && process.env.NODE_ENV !== 'development') parts.push('Secure'); res.setHeader('Set-Cookie', parts.join('; ')); return token; } @@ -9678,43 +9689,18 @@ async function sendOpenRouterChat({ messages, model }) { async function sendMistralChat({ messages, model }) { if (!MISTRAL_API_KEY) { - console.error('[MISTRAL] API key missing'); log('Mistral API key missing, cannot fulfill planning request'); throw new Error('Mistral API key is not configured'); } const safeMessages = Array.isArray(messages) ? messages : []; if (!safeMessages.length) { - console.error('[MISTRAL] Empty messages array'); throw new Error('Mistral messages must be a non-empty array'); } const resolvedModel = model || resolveMistralModel(); const payload = { model: resolvedModel, messages: safeMessages }; - console.log('[MISTRAL] Starting API request', { - url: MISTRAL_API_URL, - model: resolvedModel, - messageCount: safeMessages.length, - hasApiKey: !!MISTRAL_API_KEY, - apiKeyPrefix: MISTRAL_API_KEY ? MISTRAL_API_KEY.substring(0, 8) + '...' : 'none' - }); - - console.log('[MISTRAL] Request payload:', { - model: payload.model, - messagesCount: payload.messages.length, - firstMessage: payload.messages[0] ? { - role: payload.messages[0].role, - contentLength: payload.messages[0].content?.length || 0, - contentPreview: payload.messages[0].content?.substring(0, 100) - } : null, - lastMessage: payload.messages[payload.messages.length - 1] ? { - role: payload.messages[payload.messages.length - 1].role, - contentLength: payload.messages[payload.messages.length - 1].content?.length || 0, - contentPreview: payload.messages[payload.messages.length - 1].content?.substring(0, 100) - } : null - }); - const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${MISTRAL_API_KEY}`, @@ -9723,85 +9709,29 @@ async function sendMistralChat({ messages, model }) { try { const res = await fetch(MISTRAL_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) }); - console.log('[MISTRAL] Response received', { - status: res.status, - statusText: res.statusText, - ok: res.ok, - headers: Object.fromEntries(res.headers.entries()) - }); - if (!res.ok) { let detail = ''; try { detail = await res.text(); - console.error('[MISTRAL] Error response body:', detail); } catch (err) { - console.error('[MISTRAL] Failed to read error body', String(err)); log('Mistral error body read failed', { status: res.status, err: String(err) }); } const err = buildProviderError('Mistral', res.status, detail || res.statusText); - console.error('[MISTRAL] Request failed', { status: res.status, detail: err.detail }); log('Mistral request failed', { status: res.status, detail: err.detail || res.statusText }); throw err; } const data = await res.json(); - - // Log the FULL raw response for debugging - console.log('[MISTRAL] Full API response:', JSON.stringify(data, null, 2)); - - // Log the response data structure analysis - console.log('[MISTRAL] Response data structure:', { - hasChoices: !!data?.choices, - choicesLength: data?.choices?.length || 0, - firstChoiceKeys: data?.choices?.[0] ? Object.keys(data.choices[0]) : [], - hasMessage: !!data?.choices?.[0]?.message, - messageKeys: data?.choices?.[0]?.message ? Object.keys(data.choices[0].message) : [], - hasContent: !!data?.choices?.[0]?.message?.content, - contentLength: data?.choices?.[0]?.message?.content?.length || 0, - rawDataKeys: Object.keys(data || {}) - }); - - // Log each step of content extraction - console.log('[MISTRAL] Choices array:', data?.choices); - console.log('[MISTRAL] First choice:', data?.choices?.[0]); - console.log('[MISTRAL] Message object:', data?.choices?.[0]?.message); - console.log('[MISTRAL] Content value:', data?.choices?.[0]?.message?.content); - console.log('[MISTRAL] Content type:', typeof data?.choices?.[0]?.message?.content); - const reply = data?.choices?.[0]?.message?.content || ''; - console.log('[MISTRAL] Extracted reply:', { - reply: reply, - replyType: typeof reply, - replyLength: reply?.length || 0, - isEmpty: reply === '', - isNull: reply === null, - isUndefined: reply === undefined, - isFalsy: !reply - }); - if (!reply) { - console.error('[MISTRAL] No content in response!', { - fullData: JSON.stringify(data, null, 2), - extractedReply: reply, - replyType: typeof reply - }); - } else { - console.log('[MISTRAL] Successfully extracted reply', { - replyLength: reply.length, - replyPreview: reply.substring(0, 200) - }); + log('Mistral returned empty response', { model: resolvedModel }); } log('Mistral request succeeded', { model: resolvedModel, replyLength: reply.length }); return { reply: reply ? String(reply).trim() : '', model: resolvedModel, raw: data }; } catch (fetchErr) { - console.error('[MISTRAL] Fetch error:', { - error: String(fetchErr), - message: fetchErr.message, - stack: fetchErr.stack - }); + log('Mistral fetch error', { error: String(fetchErr) }); throw fetchErr; } } @@ -10066,8 +9996,17 @@ async function handlePlanMessage(req, res, userId) { stripMarkdownFromDisplay(sanitizePromptInput(body.displayContent)) : stripMarkdownFromDisplay(content); if (!content) return sendJson(res, 400, { error: 'Message is required' }); + + const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan'); + let historyTokens = 0; + historyMessages.forEach((m) => { + if (m.content) historyTokens += estimateTokensFromText(m.content); + if (m.reply) historyTokens += estimateTokensFromText(m.reply); + }); + const totalEstimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (historyMessages.length * TOKEN_HISTORY_OVERHEAD) + historyTokens; + const userPlan = resolveUserPlan(session.userId); - const allowance = canConsumeTokens(session.userId, userPlan, estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER); + const allowance = canConsumeTokens(session.userId, userPlan, totalEstimatedTokens); if (!allowance.allowed) { return sendJson(res, 402, { error: 'You have reached your token allowance. Upgrade or add a boost.', allowance }); } @@ -10083,7 +10022,6 @@ async function handlePlanMessage(req, res, userId) { const safePluginName = session.pluginName || `Plugin Compass ${session.title || 'Plugin'}`; let finalSystemPrompt = (systemPrompt || '').replace('{{PLUGIN_SLUG}}', safePluginSlug); finalSystemPrompt = finalSystemPrompt.replace('{{PLUGIN_NAME}}', safePluginName); - const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan'); const messages = [{ role: 'system', content: finalSystemPrompt }]; historyMessages.forEach((m) => { if (m.content) messages.push({ role: 'user', content: sanitizePromptInput(m.content) }); @@ -14172,6 +14110,11 @@ async function handleTopupCheckout(req, res) { const user = findUserById(session.userId); if (!user) return sendJson(res, 404, { error: 'User not found' }); if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' }); + + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) { + return sendJson(res, 403, { error: 'Invalid CSRF token' }); + } try { const body = await parseJsonBody(req).catch(() => ({})); @@ -14615,6 +14558,11 @@ async function handlePaygCheckout(req, res) { if (!PAYG_ENABLED) return sendJson(res, 503, { error: 'Pay-as-you-go billing is not enabled' }); if (!isPaidPlan(plan)) return sendJson(res, 400, { error: 'Pay-as-you-go is only available on paid plans' }); if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' }); + + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) { + return sendJson(res, 403, { error: 'Invalid CSRF token' }); + } const payg = computePaygSummary(user.id, plan); if (payg.billableTokens <= 0 || payg.amount <= 0) { @@ -14813,6 +14761,11 @@ async function handleSubscriptionCheckout(req, res) { const user = findUserById(session.userId); if (!user) return sendJson(res, 404, { error: 'User not found' }); if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' }); + + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) { + return sendJson(res, 403, { error: 'Invalid CSRF token' }); + } try { const body = await parseJsonBody(req); @@ -15248,6 +15201,106 @@ async function handleSubscriptionCancel(req, res) { }); } +const webhookProcessingLocks = new Map(); +const PENDING_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; +const PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +class AsyncMutex { + constructor() { + this._locked = false; + this._queue = []; + } + acquire() { + return new Promise(resolve => { + if (!this._locked) { + this._locked = true; + resolve(() => this._release()); + } else { + this._queue.push(resolve); + } + }); + } + _release() { + const next = this._queue.shift(); + if (next) { + next(() => this._release()); + } else { + this._locked = false; + } + } +} + +const paymentMutex = new Map(); + +async function withPaymentLock(paymentId, fn) { + if (!paymentMutex.has(paymentId)) { + paymentMutex.set(paymentId, new AsyncMutex()); + } + const mutex = paymentMutex.get(paymentId); + const release = await mutex.acquire(); + try { + return await fn(); + } finally { + release(); + } +} + +async function cleanupStalePendingPayments() { + const now = Date.now(); + let cleanedTopups = 0; + let cleanedPayg = 0; + let cleanedSubscriptions = 0; + + for (const [key, pending] of Object.entries(pendingTopups)) { + if (pending?.createdAt) { + const age = now - new Date(pending.createdAt).getTime(); + if (age > PENDING_MAX_AGE_MS) { + delete pendingTopups[key]; + cleanedTopups++; + } + } + } + + for (const [key, pending] of Object.entries(pendingPayg)) { + if (pending?.createdAt) { + const age = now - new Date(pending.createdAt).getTime(); + if (age > PENDING_MAX_AGE_MS) { + delete pendingPayg[key]; + cleanedPayg++; + } + } + } + + for (const [key, pending] of Object.entries(pendingSubscriptions)) { + if (pending?.createdAt) { + const age = now - new Date(pending.createdAt).getTime(); + if (age > PENDING_MAX_AGE_MS) { + delete pendingSubscriptions[key]; + cleanedSubscriptions++; + } + } + } + + if (cleanedTopups > 0 || cleanedPayg > 0 || cleanedSubscriptions > 0) { + log('Cleaned up stale pending payments', { cleanedTopups, cleanedPayg, cleanedSubscriptions }); + await Promise.all([persistPendingTopups(), persistPendingPayg(), persistPendingSubscriptions()]); + } +} + +let pendingCleanupTimer = null; + +function startPendingCleanup() { + if (pendingCleanupTimer) return; + pendingCleanupTimer = setInterval(cleanupStalePendingPayments, PENDING_CLEANUP_INTERVAL_MS); +} + +function stopPendingCleanup() { + if (pendingCleanupTimer) { + clearInterval(pendingCleanupTimer); + pendingCleanupTimer = null; + } +} + async function handleDodoWebhook(req, res) { try { const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || ''; @@ -15260,17 +15313,25 @@ async function handleDodoWebhook(req, res) { const signature = req.headers['dodo-signature'] || ''; - if (DODO_WEBHOOK_KEY && signature) { + const isProduction = process.env.NODE_ENV === 'production' || DODO_ENVIRONMENT.includes('live'); + + if (!DODO_WEBHOOK_KEY) { + if (isProduction) { + log('CRITICAL: Dodo webhook received but DODO_PAYMENTS_WEBHOOK_KEY not set in production - rejecting'); + return sendJson(res, 500, { error: 'Webhook processing not configured' }); + } + log('WARNING: Dodo webhook received without webhook key configured (development mode)'); + } else if (!signature) { + log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY }); + return sendJson(res, 401, { error: 'Missing signature' }); + } else { const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`; 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 }); + log('Dodo webhook signature verification failed', { signature: signature.substring(0, 20) + '...' }); return sendJson(res, 401, { error: 'Invalid signature' }); } - } else if (DODO_WEBHOOK_KEY) { - log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY }); - return sendJson(res, 401, { error: 'Missing signature' }); } const event = JSON.parse(rawBody); @@ -15417,68 +15478,71 @@ async function handlePaymentSucceeded(event) { if (inferredType === 'topup') { const pendingKey = (checkoutId && pendingTopups?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingTopups, orderId); const pending = pendingKey ? pendingTopups[pendingKey] : null; + const lockKey = pendingKey || paymentId || orderId; - const tokens = Number(metadata.tokens || pending?.tokens || 0); - if (!tokens) { - log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId }); - return; - } + await withPaymentLock(lockKey, async () => { + const tokens = Number(metadata.tokens || pending?.tokens || 0); + if (!tokens) { + log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId }); + return; + } - const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount); - const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null; - const tier = metadata.tier || pending?.tier || null; + const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount); + const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null; + const tier = metadata.tier || pending?.tier || null; + + if (pendingKey && processedTopups[pendingKey]) { + await ensureInvoice('topup', { + tokens, + amount: processedTopups[pendingKey].amount ?? amount, + currency: processedTopups[pendingKey].currency ?? currency, + tier: processedTopups[pendingKey].tier ?? tier, + source: { + provider: 'dodo', + checkoutId: pendingKey, + orderId: processedTopups[pendingKey].orderId || orderId, + paymentId: processedTopups[pendingKey].paymentId || paymentId, + eventId: event.id, + }, + }); + return; + } + + const bucket = ensureTokenUsageBucket(userId); + bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens); + await persistTokenUsage(); + + if (pendingKey) { + processedTopups[pendingKey] = { + userId: user.id, + orderId: (pending?.orderId || orderId) || null, + paymentId: paymentId || null, + tokens, + tier: tier || null, + amount, + currency, + completedAt: new Date().toISOString(), + }; + delete pendingTopups[pendingKey]; + await Promise.all([persistTopupSessions(), persistPendingTopups()]); + } - if (pendingKey && processedTopups[pendingKey]) { await ensureInvoice('topup', { tokens, - amount: processedTopups[pendingKey].amount ?? amount, - currency: processedTopups[pendingKey].currency ?? currency, - tier: processedTopups[pendingKey].tier ?? tier, + amount, + currency, + tier, source: { provider: 'dodo', - checkoutId: pendingKey, - orderId: processedTopups[pendingKey].orderId || orderId, - paymentId: processedTopups[pendingKey].paymentId || paymentId, + checkoutId: pendingKey || checkoutId, + orderId, + paymentId, eventId: event.id, }, }); - return; - } - const bucket = ensureTokenUsageBucket(userId); - bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens); - await persistTokenUsage(); - - if (pendingKey) { - processedTopups[pendingKey] = { - userId: user.id, - orderId: (pending?.orderId || orderId) || null, - paymentId: paymentId || null, - tokens, - tier: tier || null, - amount, - currency, - completedAt: new Date().toISOString(), - }; - delete pendingTopups[pendingKey]; - await Promise.all([persistTopupSessions(), persistPendingTopups()]); - } - - await ensureInvoice('topup', { - tokens, - amount, - currency, - tier, - source: { - provider: 'dodo', - checkoutId: pendingKey || checkoutId, - orderId, - paymentId, - eventId: event.id, - }, + log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId }); }); - - log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId }); return; } @@ -16273,6 +16337,16 @@ async function handleVerifyEmailApi(req, res, url) { async function handlePasswordResetRequest(req, res) { try { + const clientIp = req.socket?.remoteAddress || 'unknown'; + const rateLimit = checkLoginRateLimit(clientIp, 5, passwordResetAttempts); + if (rateLimit.blocked) { + log('password reset rate limited', { ip: clientIp, retryAfter: rateLimit.retryAfter }); + return sendJson(res, 429, { + error: 'Too many password reset attempts. Please try again later.', + retryAfter: rateLimit.retryAfter || 60 + }); + } + const body = await parseJsonBody(req); const email = (body.email || '').trim().toLowerCase(); if (!email) return sendJson(res, 400, { error: 'Email is required' }); @@ -16280,11 +16354,14 @@ async function handlePasswordResetRequest(req, res) { const user = findUserByEmail(email); if (!user) { await new Promise((resolve) => setTimeout(resolve, 250)); + passwordResetAttempts.delete(clientIp); return sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' }); } assignPasswordResetToken(user); await persistUsersDb(); + + passwordResetAttempts.delete(clientIp); // Send reset email in the background sendPasswordResetEmail(user, resolveBaseUrl(req)).catch(err => { @@ -18483,7 +18560,15 @@ async function handleNewMessage(req, res, sessionId, userId) { } } const model = resolvePlanModel(userPlan, body.model || session.model); - const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER; // include headroom for reply + + const sessionHistory = (session.messages || []).slice(-20); + let historyTokens = 0; + sessionHistory.forEach((m) => { + if (m.content) historyTokens += estimateTokensFromText(m.content); + if (m.reply) historyTokens += estimateTokensFromText(m.reply); + }); + const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (sessionHistory.length * TOKEN_HISTORY_OVERHEAD) + historyTokens; + const allowance = canConsumeTokens(session.userId, userPlan, estimatedTokens); if (!allowance.allowed) { const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance'; @@ -20263,8 +20348,15 @@ async function routeInternal(req, res, url, pathname) { if (req.method === 'GET' && pathname === '/api/verify-email') return handleVerifyEmailApi(req, res, url); if (req.method === 'POST' && pathname === '/api/password/forgot') return handlePasswordResetRequest(req, res); - // Dev helper: preview branded email templates without sending + // Dev helper: preview branded email templates without sending (admin only) if (req.method === 'GET' && pathname === '/debug/email/preview') { + const adminSession = requireAdminAuth(req, res); + if (!adminSession) return; + + if (process.env.NODE_ENV === 'production') { + return sendJson(res, 403, { error: 'Debug endpoints disabled in production' }); + } + const type = url.searchParams.get('type') || 'verification'; const email = url.searchParams.get('email') || 'user@example.com'; const token = url.searchParams.get('token') || 'sample-token'; @@ -20901,6 +20993,9 @@ async function bootstrap() { // Start memory cleanup scheduler startMemoryCleanup(); + // Start pending payment cleanup scheduler + startPendingCleanup(); + // Start periodic resource monitoring for analytics startPeriodicMonitoring();