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 <noreply@anthropic.com>
This commit is contained in:
Tengku Achmad 2026-08-03 22:48:37 +07:00
parent 04c9ec233e
commit 441e6261a2
3 changed files with 159 additions and 57 deletions

3
.gitignore vendored
View File

@ -1,6 +1,9 @@
# Logs # Logs
logs logs
*.log *.log
# Persisted server state
server/data/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*

View File

@ -5,7 +5,7 @@ module.exports = {
script : "server/index.cjs", script : "server/index.cjs",
env: { env: {
NODE_ENV: "development", NODE_ENV: "production",
} }
}, },
{ {

View File

@ -29,9 +29,53 @@ const TransactionLogger = {
// EXPRESS APP SETUP // EXPRESS APP SETUP
// ============================================================================ // ============================================================================
const app = express() 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()) 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 // MIDTRANS CONFIGURATION
// ============================================================================ // ============================================================================
@ -86,8 +130,83 @@ const ERP_NOTIFICATION_URLS = (() => {
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : [] 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 // IN-MEMORY TRACKING UNTUK PREVENT DUPLICATE NOTIFICATIONS
const notifiedOrders = new Set() const notifiedOrders = watchedSet(PERSISTED_STATE.notifiedOrders)
// ============================================================================ // ============================================================================
// LOGGING UTILITIES // LOGGING UTILITIES
@ -107,6 +226,32 @@ if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true }) 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) // GET LOG FILENAME (LOGS_DDMMYYYY.LOG)
function getLogFilename() { function getLogFilename() {
const now = new Date() 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_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay' const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
// IN-MEMORY STORAGE // PERSISTED STORAGE (SURVIVES RESTARTS — SEE watchedMap ABOVE)
const activeOrders = new Map() const activeOrders = watchedMap(PERSISTED_STATE.activeOrders)
const orderMerchantId = new Map() const orderMerchantId = watchedMap(PERSISTED_STATE.orderMerchantId)
const orderRetryCount = new Map() const orderRetryCount = watchedMap(PERSISTED_STATE.orderRetryCount)
// CHECK IF DEVELOPMENT ENVIRONMENT // CHECK IF DEVELOPMENT ENVIRONMENT
function isDevEnv() { function isDevEnv() {
@ -521,7 +666,7 @@ app.get('/api/payment-links/:token', (req, res) => {
* POST /api/payment-links * POST /api/payment-links
* Requires: X-API-KEY header * Requires: X-API-KEY header
*/ */
app.post('/api/payment-links', async (req, res) => { app.post('/api/payment-links', rateLimit, async (req, res) => {
try { try {
if (!verifyExternalKey(req)) { if (!verifyExternalKey(req)) {
logWarn('payment-links.create.unauthorized', { id: req.id }) 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 * Create payment transaction via Midtrans Core API
* POST /api/payments/charge * POST /api/payments/charge
*/ */
app.post('/api/payments/charge', async (req, res) => { app.post('/api/payments/charge', rateLimit, async (req, res) => {
try { try {
const pt = req?.body?.payment_type const pt = req?.body?.payment_type
logInfo('charge.request', { id: req.id, payment_type: pt }) 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 * Generate Snap token for hosted payment interface
* POST /api/payments/snap/token * 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 { try {
const snap = new midtransClient.Snap({ const snap = new midtransClient.Snap({
isProduction, isProduction,
@ -752,7 +897,7 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
* POST /createtransaksi * POST /createtransaksi
* Requires: X-API-KEY header * Requires: X-API-KEY header
*/ */
app.post('/createtransaksi', async (req, res) => { app.post('/createtransaksi', rateLimit, async (req, res) => {
try { try {
if (!verifyExternalKey(req)) { if (!verifyExternalKey(req)) {
logWarn('createtransaksi.unauthorized', { id: req.id }) 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) * Compute Midtrans webhook signature (SHA-512)
*/ */