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:
parent
c671468462
commit
35bb27fc38
|
|
@ -338,11 +338,52 @@ app.get('/api/config', (_req, res) => {
|
||||||
res.json(payload)
|
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 recent logs from memory (dev/debug only)
|
||||||
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
* 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) {
|
if (!LOG_EXPOSE_API) {
|
||||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
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
|
* List all log files in logs directory
|
||||||
* GET /api/logs/files
|
* GET /api/logs/files
|
||||||
*/
|
*/
|
||||||
app.get('/api/logs/files', (req, res) => {
|
app.get('/api/logs/files', requireLogBasicAuth, (req, res) => {
|
||||||
if (!LOG_EXPOSE_API) {
|
if (!LOG_EXPOSE_API) {
|
||||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
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
|
* Read specific log file
|
||||||
* GET /api/logs/files/:filename
|
* 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) {
|
if (!LOG_EXPOSE_API) {
|
||||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
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 ':')
|
// 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
|
// 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
|
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 = {
|
const customer = {
|
||||||
name: customerIn.name,
|
name: customerIn.name,
|
||||||
phone: customerIn.phone,
|
phone: customerIn.phone,
|
||||||
|
|
@ -521,12 +568,12 @@ app.post('/api/payment-links', async (req, res) => {
|
||||||
expire_at = now + ttlMin * 60 * 1000
|
expire_at = now + ttlMin * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notifiedOrders.has(order_id)) {
|
if (notifiedOrders.has(sanitizedOrderId)) {
|
||||||
logWarn('payment-links.create.completed', { order_id })
|
logWarn('payment-links.create.completed', { order_id })
|
||||||
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
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) {
|
if (existing && existing > now) {
|
||||||
logWarn('payment-links.create.active_exists', { order_id })
|
logWarn('payment-links.create.active_exists', { order_id })
|
||||||
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
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 token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods })
|
||||||
const url = `${PAYMENT_LINK_BASE}/${token}`
|
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||||
activeOrders.set(order_id, expire_at)
|
activeOrders.set(sanitizedOrderId, expire_at)
|
||||||
orderMerchantId.set(order_id, mercantId)
|
orderMerchantId.set(sanitizedOrderId, mercantId)
|
||||||
if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId)
|
if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, mercantId)
|
||||||
|
|
||||||
logInfo('payment-links.create.issued', { order_id, midtrans_order_id: midtransOrderId, expire_at })
|
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 } })
|
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 items = Array.isArray(req?.body?.item) ? req.body.item : []
|
||||||
const primaryItemId = items?.[0]?.item_id
|
const primaryItemId = items?.[0]?.item_id
|
||||||
|
|
||||||
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
|
|
||||||
const order_id = String(
|
const order_id = String(
|
||||||
(primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` :
|
(primaryItemId && mercantId) ? `${mercantId}.${primaryItemId}` :
|
||||||
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compute Midtrans-specific order_id — append retry suffix when the previous attempt expired
|
// Midtrans order_id only allows alphanumeric characters and - _ ~ . (no ':')
|
||||||
const retryCount = orderRetryCount.get(order_id) || 0
|
// order_id may still contain ':' here if it came straight from req.body.order_id/item_id
|
||||||
const midtransOrderId = retryCount > 0 ? `${order_id}.r${retryCount}` : order_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 {
|
try {
|
||||||
orderMerchantId.set(order_id, mercantId)
|
orderMerchantId.set(sanitizedOrderId, resolvedMercantId)
|
||||||
if (midtransOrderId !== order_id) orderMerchantId.set(midtransOrderId, mercantId)
|
if (midtransOrderId !== sanitizedOrderId) orderMerchantId.set(midtransOrderId, resolvedMercantId)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,12 +805,12 @@ app.post('/createtransaksi', async (req, res) => {
|
||||||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
||||||
const expire_at = now + ttlMin * 60 * 1000
|
const expire_at = now + ttlMin * 60 * 1000
|
||||||
|
|
||||||
if (notifiedOrders.has(order_id)) {
|
if (notifiedOrders.has(sanitizedOrderId)) {
|
||||||
logWarn('createtransaksi.completed', { order_id })
|
logWarn('createtransaksi.completed', { order_id })
|
||||||
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
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) {
|
if (existing && existing > now) {
|
||||||
logWarn('createtransaksi.active_exists', { order_id })
|
logWarn('createtransaksi.active_exists', { order_id })
|
||||||
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
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 token = createPaymentLinkToken({ order_id: midtransOrderId, nominal, expire_at, customer, allowed_methods })
|
||||||
const url = `${PAYMENT_LINK_BASE}/${token}`
|
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 })
|
logInfo('createtransaksi.issued', { order_id, midtrans_order_id: midtransOrderId, retry_count: retryCount, expire_at })
|
||||||
|
|
||||||
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue