fix: align order_id state keys and secure log endpoints with Basic Auth

State maps (activeOrders, notifiedOrders, orderRetryCount, orderMerchantId)
were keyed by the raw order_id in /createtransaksi and /api/payment-links,
but Midtrans webhooks and status checks always report back the sanitized
('.'-joined) order_id. Whenever the original order_id contained characters
Midtrans disallows (e.g. ':'), the two never matched — breaking duplicate/
already-completed detection and retry-suffix lookups. Both endpoints now
key exclusively on the sanitized order_id, with retry-suffix support added
to /api/payment-links for parity with /createtransaksi.

Also add HTTP Basic Auth (requireLogBasicAuth) in front of /api/logs,
/api/logs/files, and /api/logs/files/:filename — these were previously
gated only by LOG_EXPOSE_API, which defaults to true, leaving order data
and transaction details publicly readable with no credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tengku Achmad 2026-08-03 22:03:47 +07:00
parent c671468462
commit 35bb27fc38
1 changed files with 74 additions and 19 deletions

View File

@ -338,11 +338,52 @@ app.get('/api/config', (_req, res) => {
res.json(payload)
})
// ============================================================================
// LOG ACCESS AUTHENTICATION (HTTP BASIC AUTH — browser-native login prompt)
// ============================================================================
const LOG_BASIC_AUTH_USER = process.env.LOG_BASIC_AUTH_USER || ''
const LOG_BASIC_AUTH_PASS = process.env.LOG_BASIC_AUTH_PASS || ''
function timingSafeEqualStr(a, b) {
const bufA = Buffer.from(String(a))
const bufB = Buffer.from(String(b))
if (bufA.length !== bufB.length) return false
return crypto.timingSafeEqual(bufA, bufB)
}
function requireLogBasicAuth(req, res, next) {
if (!LOG_BASIC_AUTH_USER || !LOG_BASIC_AUTH_PASS) {
if (isDevEnv()) return next()
logWarn('logs.auth.not_configured', { id: req.id })
return res.status(503).json({ error: 'LOG_AUTH_NOT_CONFIGURED', message: 'Set LOG_BASIC_AUTH_USER and LOG_BASIC_AUTH_PASS to enable log access.' })
}
const header = req.headers['authorization'] || ''
const [scheme, encoded] = header.split(' ')
let user = '', pass = ''
if (scheme === 'Basic' && encoded) {
try {
const decoded = Buffer.from(encoded, 'base64').toString('utf8')
const idx = decoded.indexOf(':')
user = idx >= 0 ? decoded.slice(0, idx) : decoded
pass = idx >= 0 ? decoded.slice(idx + 1) : ''
} catch {}
}
const ok = user && pass && timingSafeEqualStr(user, LOG_BASIC_AUTH_USER) && timingSafeEqualStr(pass, LOG_BASIC_AUTH_PASS)
if (!ok) {
if (scheme === 'Basic') logWarn('logs.auth.failed', { id: req.id, user })
res.set('WWW-Authenticate', 'Basic realm="Midtrans Middleware Logs"')
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'Login required to access logs' })
}
next()
}
/**
* Get recent logs from memory (dev/debug only)
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
*/
app.get('/api/logs', (req, res) => {
app.get('/api/logs', requireLogBasicAuth, (req, res) => {
if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
@ -365,7 +406,7 @@ app.get('/api/logs', (req, res) => {
* List all log files in logs directory
* GET /api/logs/files
*/
app.get('/api/logs/files', (req, res) => {
app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
@ -393,7 +434,7 @@ app.get('/api/logs/files', (req, res) => {
* Read specific log file
* GET /api/logs/files/:filename
*/
app.get('/api/logs/files/:filename', (req, res) => {
app.get('/api/logs/files/:filename', requireLogBasicAuth, (req, res) => {
if (!LOG_EXPOSE_API) {
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
}
@ -504,10 +545,16 @@ app.post('/api/payment-links', async (req, res) => {
}
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
const midtransOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.')
const sanitizedOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.')
// ERP convention: "mercant_id:item_id" — take the segment before ':' as mercant_id for ERP callback matching
const mercantId = order_id.includes(':') ? order_id.split(':')[0] : order_id
// Append retry suffix when a previous link for this order expired — Midtrans never allows
// reusing an order_id, even an expired one. Keyed by sanitizedOrderId so this stays aligned
// with getBaseOrderId(), which strips the retry suffix from what the webhook sends back.
const retryCount = orderRetryCount.get(sanitizedOrderId) || 0
const midtransOrderId = retryCount > 0 ? `${sanitizedOrderId}.r${retryCount}` : sanitizedOrderId
const customer = {
name: customerIn.name,
phone: customerIn.phone,
@ -521,12 +568,12 @@ app.post('/api/payment-links', async (req, res) => {
expire_at = now + ttlMin * 60 * 1000
}
if (notifiedOrders.has(order_id)) {
if (notifiedOrders.has(sanitizedOrderId)) {
logWarn('payment-links.create.completed', { order_id })
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
}
const existing = activeOrders.get(order_id)
const existing = activeOrders.get(sanitizedOrderId)
if (existing && existing > now) {
logWarn('payment-links.create.active_exists', { order_id })
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
@ -552,9 +599,9 @@ app.post('/api/payment-links', async (req, res) => {
const token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods })
const url = `${PAYMENT_LINK_BASE}/${token}`
activeOrders.set(order_id, expire_at)
orderMerchantId.set(order_id, mercantId)
if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId)
activeOrders.set(sanitizedOrderId, expire_at)
orderMerchantId.set(sanitizedOrderId, mercantId)
if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, mercantId)
logInfo('payment-links.create.issued', { order_id, midtrans_order_id: midtransOrderId, expire_at })
res.json({ status: '200', messages: 'SUCCESS', data: { url, order_id, midtrans_order_id: midtransOrderId, expire_at } })
@ -717,20 +764,28 @@ app.post('/createtransaksi', async (req, res) => {
const items = Array.isArray(req?.body?.item) ? req.body.item : []
const primaryItemId = items?.[0]?.item_id
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
const order_id = String(
(primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` :
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
)
// Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired
const retryCount = orderRetryCount.get(order_id) || 0
const midtransOrderId = retryCount > 0 ? `${order_id}.r${retryCount}` : order_id
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
// order_id may still contain ':' here if it came straight from req.body.order_id/item_id
// instead of the mercant_id+item[] branch, so sanitize before it's ever sent to Midtrans.
const sanitizedOrderId = order_id.replace(/[^A-Za-z0-9\-_~.]/g, '.')
if (mercantId) {
// Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired.
// Keyed by sanitizedOrderId (not the raw order_id) so this stays aligned with getBaseOrderId(),
// which strips the retry suffix from the Midtrans-form id the webhook sends back.
const retryCount = orderRetryCount.get(sanitizedOrderId) || 0
const midtransOrderId = retryCount > 0 ? `${sanitizedOrderId}.r${retryCount}` : sanitizedOrderId
// ERP convention: "mercant_id:item_id" — fall back to the segment before ':' when mercant_id wasn't sent explicitly
const resolvedMercantId = mercantId || (order_id.includes(':') ? order_id.split(':')[0] : '')
if (resolvedMercantId) {
try {
orderMerchantId.set(order_id, mercantId)
if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId)
orderMerchantId.set(sanitizedOrderId, resolvedMercantId)
if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, resolvedMercantId)
} catch {}
}
@ -750,12 +805,12 @@ app.post('/createtransaksi', async (req, res) => {
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
const expire_at = now + ttlMin * 60 * 1000
if (notifiedOrders.has(order_id)) {
if (notifiedOrders.has(sanitizedOrderId)) {
logWarn('createtransaksi.completed', { order_id })
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
}
const existing = activeOrders.get(order_id)
const existing = activeOrders.get(sanitizedOrderId)
if (existing && existing > now) {
logWarn('createtransaksi.active_exists', { order_id })
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
@ -783,7 +838,7 @@ app.post('/createtransaksi', async (req, res) => {
const token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods })
const url = `${PAYMENT_LINK_BASE}/${token}`
activeOrders.set(order_id, expire_at)
activeOrders.set(sanitizedOrderId, expire_at)
logInfo('createtransaksi.issued', { order_id, midtrans_order_id: midtransOrderId, retry_count: retryCount, expire_at })
res.json({ status: '200', messages: 'SUCCESS', data: { url } })