From 441e6261a21ace832ff21128c456ef63e6307929 Mon Sep 17 00:00:00 2001 From: TengkuAchmad Date: Mon, 3 Aug 2026 22:48:37 +0700 Subject: [PATCH] fix: harden payment-creation endpoints and persist server state - ecosystem.config.cjs: NODE_ENV was hardcoded to "development" even for the production pm2 process, silently disabling every isDevEnv()-gated security check (API key bypass, admin auth bypass) in production. Now "production". - Restrict CORS via configurable CORS_ALLOWED_ORIGINS (falls back to allow-all with a warning when unset, so this doesn't break existing traffic on deploy). - Add an in-process, dependency-free rate limiter on /createtransaksi, /api/payment-links, /api/payments/charge, and /api/payments/snap/token to curb bot abuse / card-testing on endpoints the browser checkout must be able to call directly (an API-key gate would break that legitimate flow). - Auto-delete LOGS_*.log files older than LOG_RETENTION_DAYS (default 30). - Persist activeOrders, notifiedOrders, orderRetryCount, and orderMerchantId to server/data/state.json via Proxy-wrapped Map/Set (schedulePersist on every mutation), so idempotency and retry-suffix tracking survive restarts/crashes instead of resetting to empty every deploy. - Remove processPaymentCompletion, dead code with no call sites. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 + ecosystem.config.cjs | 2 +- server/index.cjs | 211 +++++++++++++++++++++++++++++++------------ 3 files changed, 159 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index ecee33b..84849a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Logs logs *.log + +# Persisted server state +server/data/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index f31013d..831c79e 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -5,7 +5,7 @@ module.exports = { script : "server/index.cjs", env: { - NODE_ENV: "development", + NODE_ENV: "production", } }, { diff --git a/server/index.cjs b/server/index.cjs index 3e11baf..1084981 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -29,9 +29,53 @@ const TransactionLogger = { // EXPRESS APP SETUP // ============================================================================ const app = express() -app.use(cors()) + +// CORS — restrict to known frontend origins via CORS_ALLOWED_ORIGINS (comma-separated). +// Falls back to allowing all origins (with a warning) when unset, so this ships without +// breaking existing traffic until the env var is explicitly configured. +const CORS_ALLOWED_ORIGINS = parseList(process.env.CORS_ALLOWED_ORIGINS) +if (CORS_ALLOWED_ORIGINS.length > 0) { + app.use(cors({ origin: CORS_ALLOWED_ORIGINS, credentials: true })) +} else { + console.warn('[CORS] CORS_ALLOWED_ORIGINS not set — allowing all origins. Set it in production to restrict access.') + app.use(cors()) +} app.use(express.json()) +// ============================================================================ +// SIMPLE IN-PROCESS RATE LIMITER (NO EXTERNAL DEPENDENCY) +// Protects payment-creation endpoints from bot abuse / card-testing without +// requiring an API key on endpoints the customer-facing checkout calls directly. +// ============================================================================ +const RATE_LIMIT_WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10) +const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '20', 10) +const rateLimitBuckets = new Map() // key (ip) -> { count, resetAt } + +function rateLimit(req, res, next) { + const key = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim() + const now = Date.now() + let bucket = rateLimitBuckets.get(key) + if (!bucket || now > bucket.resetAt) { + bucket = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS } + rateLimitBuckets.set(key, bucket) + } + bucket.count += 1 + if (bucket.count > RATE_LIMIT_MAX) { + logWarn('rate_limit.blocked', { key, path: req.path, count: bucket.count }) + res.set('Retry-After', String(Math.ceil((bucket.resetAt - now) / 1000))) + return res.status(429).json({ error: 'RATE_LIMITED', message: 'Too many requests, please try again later.' }) + } + next() +} + +// PERIODIC CLEANUP SO INACTIVE IP ENTRIES DON'T GROW THE MAP FOREVER +setInterval(() => { + const now = Date.now() + for (const [key, bucket] of rateLimitBuckets) { + if (now > bucket.resetAt) rateLimitBuckets.delete(key) + } +}, RATE_LIMIT_WINDOW_MS).unref() + // ============================================================================ // MIDTRANS CONFIGURATION // ============================================================================ @@ -86,8 +130,83 @@ const ERP_NOTIFICATION_URLS = (() => { return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : [] })() +// ============================================================================ +// PERSISTENT STATE (SURVIVES RESTARTS) — wraps Map/Set in Proxies that +// auto-persist to disk on mutation, so activeOrders/notifiedOrders/ +// orderRetryCount/orderMerchantId call sites elsewhere don't need to change. +// ============================================================================ +const STATE_FILE = path.join(__dirname, 'data', 'state.json') +try { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }) +} catch {} + +let persistTimer = null +function schedulePersist() { + if (persistTimer) return + persistTimer = setTimeout(() => { + persistTimer = null + try { + fs.writeFileSync(STATE_FILE, JSON.stringify({ + activeOrders: [...activeOrders.entries()], + notifiedOrders: [...notifiedOrders], + orderRetryCount: [...orderRetryCount.entries()], + orderMerchantId: [...orderMerchantId.entries()], + }), 'utf8') + } catch (e) { + console.warn('[state-persist] Failed to write state file:', e.message) + } + }, 250) + persistTimer.unref() +} + +function loadPersistedState() { + try { + if (!fs.existsSync(STATE_FILE)) return {} + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) + } catch (e) { + console.warn('[state-persist] Failed to read state file:', e.message) + return {} + } +} + +function watchedMap(initialEntries) { + const map = new Map(initialEntries || []) + return new Proxy(map, { + get(target, prop) { + const value = target[prop] + if (prop === 'set' || prop === 'delete') { + return (...args) => { + const result = value.apply(target, args) + schedulePersist() + return result + } + } + return typeof value === 'function' ? value.bind(target) : value + }, + }) +} + +function watchedSet(initialValues) { + const set = new Set(initialValues || []) + return new Proxy(set, { + get(target, prop) { + const value = target[prop] + if (prop === 'add' || prop === 'delete') { + return (...args) => { + const result = value.apply(target, args) + schedulePersist() + return result + } + } + return typeof value === 'function' ? value.bind(target) : value + }, + }) +} + +const PERSISTED_STATE = loadPersistedState() + // IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS -const notifiedOrders = new Set() +const notifiedOrders = watchedSet(PERSISTED_STATE.notifiedOrders) // ============================================================================ // LOGGING UTILITIES @@ -107,6 +226,32 @@ if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }) } +// LOG RETENTION — DELETE LOGS_*.log FILES OLDER THAN LOG_RETENTION_DAYS +const LOG_RETENTION_DAYS = parseInt(process.env.LOG_RETENTION_DAYS || '30', 10) + +function cleanupOldLogs() { + try { + const maxAgeMs = LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 + const now = Date.now() + const files = fs.readdirSync(LOG_DIR).filter((f) => f.startsWith('LOGS_') && f.endsWith('.log')) + let deleted = 0 + for (const f of files) { + const filePath = path.join(LOG_DIR, f) + const stats = fs.statSync(filePath) + if (now - stats.mtimeMs > maxAgeMs) { + fs.unlinkSync(filePath) + deleted += 1 + } + } + if (deleted > 0) console.log(`[log-cleanup] Deleted ${deleted} log file(s) older than ${LOG_RETENTION_DAYS} days`) + } catch (e) { + console.warn('[log-cleanup] Failed:', e.message) + } +} + +cleanupOldLogs() +setInterval(cleanupOldLogs, 24 * 60 * 60 * 1000).unref() + // GET LOG FILENAME (LOGS_DDMMYYYY.LOG) function getLogFilename() { const now = new Date() @@ -236,10 +381,10 @@ const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || '' const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10) const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay' -// IN-MEMORY STORAGE -const activeOrders = new Map() -const orderMerchantId = new Map() -const orderRetryCount = new Map() +// PERSISTED STORAGE (SURVIVES RESTARTS — SEE watchedMap ABOVE) +const activeOrders = watchedMap(PERSISTED_STATE.activeOrders) +const orderMerchantId = watchedMap(PERSISTED_STATE.orderMerchantId) +const orderRetryCount = watchedMap(PERSISTED_STATE.orderRetryCount) // CHECK IF DEVELOPMENT ENVIRONMENT function isDevEnv() { @@ -521,7 +666,7 @@ app.get('/api/payment-links/:token', (req, res) => { * POST /api/payment-links * Requires: X-API-KEY header */ -app.post('/api/payment-links', async (req, res) => { +app.post('/api/payment-links', rateLimit, async (req, res) => { try { if (!verifyExternalKey(req)) { logWarn('payment-links.create.unauthorized', { id: req.id }) @@ -619,7 +764,7 @@ app.post('/api/payment-links', async (req, res) => { * Create payment transaction via Midtrans Core API * POST /api/payments/charge */ -app.post('/api/payments/charge', async (req, res) => { +app.post('/api/payments/charge', rateLimit, async (req, res) => { try { const pt = req?.body?.payment_type logInfo('charge.request', { id: req.id, payment_type: pt }) @@ -681,7 +826,7 @@ app.post('/api/payments/charge', async (req, res) => { * Generate Snap token for hosted payment interface * POST /api/payments/snap/token */ -app.post('/api/payments/snap/token', async (req, res) => { +app.post('/api/payments/snap/token', rateLimit, async (req, res) => { try { const snap = new midtransClient.Snap({ isProduction, @@ -752,7 +897,7 @@ app.get('/api/payments/:orderId/status', async (req, res) => { * POST /createtransaksi * Requires: X-API-KEY header */ -app.post('/createtransaksi', async (req, res) => { +app.post('/createtransaksi', rateLimit, async (req, res) => { try { if (!verifyExternalKey(req)) { logWarn('createtransaksi.unauthorized', { id: req.id }) @@ -961,52 +1106,6 @@ function updateLedger(orderId, data) { } } -/** - * Process payment completion and trigger ERP notifications - * Unified handler for both CORE and SNAP modes - */ -async function processPaymentCompletion(orderId, internalStatus, mode, body) { - try { - logInfo(`[${mode}] payment.process`, { - order_id: orderId, - internal_status: internalStatus, - transaction_status: body?.transaction_status - }) - if (internalStatus === 'completed') { - const grossAmount = body?.gross_amount - const nominal = String(grossAmount || '') - - if (!notifiedOrders.has(orderId)) { - activeOrders.delete(orderId) - - const mercantId = resolveMercantId(orderId) - const ok = await notifyERP({ orderId, nominal, mercantId }) - - if (ok) { - notifiedOrders.add(orderId) - logInfo(`[${mode}] erp.notify.success`, { order_id: orderId }) - } else { - logWarn(`[${mode}] erp.notify.failed`, { order_id: orderId }) - } - } else { - logInfo(`[${mode}] erp.notify.skip`, { order_id: orderId, reason: 'already_notified' }) - } - } else { - logInfo(`[${mode}] payment.non_success`, { - order_id: orderId, - internal_status: internalStatus, - transaction_status: body?.transaction_status - }) - } - - } catch (e) { - logError(`[${mode}] payment.process.error`, { - order_id: orderId, - message: e?.message - }) - } -} - /** * Compute Midtrans webhook signature (SHA-512) */