SuperAPI AI Directory

cc-payment-callback-safety

Security & auditing

View repository

Updated 10-15 21:11

About this skill

Payment Callback Security Specification

When the system involves third-party payment callbacks (WeChat Pay, Alipay, etc.) or webhook notifications, prevent forgery, tampering, and replay attacks.


Pitfall #1: Callbacks Not Verified Against the Platform Signature

Scenario: After receiving a payment callback, decrypt/parse and process it directly without verifying that the request actually comes from the payment platform

Root Cause

The payment callback URL is exposed on the public internet, so anyone can forge requests. If the signature is not verified, an attacker can construct a fake "payment successful" notification.

Incorrect Example

// ❌ 错误: 直接解密处理,未验签
@PostMapping("/payment/callback/wechat")
public String wechatCallback(@RequestBody String body) {
    JsonNode json = objectMapper.readTree(body);
    // 直接尝试解密 resource
    String decrypted = decryptAesGcm(ciphertext, apiV3Key, nonce, aad);
    processPayment(decrypted);  // 谁发来的都处理
    return "{\"code\":\"SUCCESS\"}";
}

Correct Approach

// ✅ 正确: 先验签,再解密,再处理
@PostMapping("/payment/callback/wechat")
public String wechatCallback(
    @RequestBody String body,
    @RequestHeader("Wechatpay-Signature") String signature,
    @RequestHeader("Wechatpay-Timestamp") String timestamp,
    @RequestHeader("Wechatpay-Nonce") String nonce,
    @RequestHeader("Wechatpay-Serial") String serial) {

    // 1. 用微信支付平台公钥验签(不是商户私钥)
    String message = timestamp + "\n" + nonce + "\n" + body + "\n";
    PublicKey platformKey = loadPlatformPublicKey(serial);
    if (!verifySignature(message, signature, platformKey)) {
        return "{\"code\":\"FAIL\",\"message\":\"验签失败\"}";
    }

    // 2. 验签通过后再解密和处理
    String decrypted = decryptAesGcm(ciphertext, apiV3Key, nonce, aad);
    processPayment(decrypted);
    return "{\"code\":\"SUCCESS\"}";
}

Key Distinctions

Key/Certificate Purpose Holder
Merchant Private Key Signing when the merchant requests WeChat Merchant
Merchant API Certificate Merchant identity identifier Merchant
WeChat Pay Platform Public Key/Certificate Verify WeChat callback signature Issued by the WeChat platform; the merchant holds the public key
API V3 Key Decrypt the callback notification body Merchant

Checklist

  • Does the callback entry point verify the platform signature before decryption
  • Does signature verification use the platform public key/certificate rather than the merchant private key
  • Does it match the corresponding platform certificate by Wechatpay-Serial (supporting certificate rotation)
  • If signature verification fails, is the request directly rejected without further processing

Pitfall #2: No Anti-Replay Validation

Scenario: An attacker intercepts a genuine payment success callback and repeatedly sends it to the system

Root Cause

The signature of a legitimate old callback message is still valid; signature verification alone cannot prevent replay.

Incorrect Example

// ❌ 错误: 验签通过就直接处理,不检查是否重复
if (verifySignature(message, signature, platformKey)) {
    processPayment(decrypted);  // 同一笔单可能被处理多次
}

Correct Approach

// ✅ 正确: 三层防重放
// 第 1 层: 时间戳窗口
long callbackTime = Long.parseLong(timestamp);
long now = Instant.now().getEpochSecond();
if (Math.abs(now - callbackTime) > 300) {  // 5 分钟窗口
    return "{\"code\":\"FAIL\",\"message\":\"timestamp expired\"}";
}

// 第 2 层: 按 transaction_id 幂等
String transactionId = payData.get("transaction_id").asText();
if (paymentRecordRepository.existsByTransactionIdAndStatus(
        transactionId, "SUCCESS")) {
    log.info("重复通知,已处理: transactionId={}", transactionId);
    return "{\"code\":\"SUCCESS\"}";  // 返回成功,让平台停止重试
}

// 第 3 层: 订单状态机幂等
TradeOrder order = orderRepository.findByOrderNumber(outTradeNo);
if (order.getPaymentStatus() == PaymentStatus.PAID) {
    log.info("订单已支付,跳过: orderId={}", order.getId());
    return "{\"code\":\"SUCCESS\"}";
}

Checklist

  • Is the callback timestamp window validated (5 minutes recommended)
  • Is idempotency deduplication performed by transaction_id
  • Is the current order status checked (to prevent an already-paid order from being processed again)
  • Do duplicate notifications return success (so the platform stops retrying)

Pitfall #3: Trusting the Amount in the Callback

Scenario: Directly using the amount in the callback message as the basis for crediting, without verifying it against the local order amount

Root Cause

Even if signature verification passes, consistency validation should be performed based on the local order amount to prevent order mismatches or, in extreme cases, amount inconsistencies.

Incorrect Example

// ❌ 错误: 直接用回调金额入账
int paidAmount = payData.get("amount").get("total").asInt();
order.setPaidAmount(paidAmount);  // 不校验是否与下单金额一致
order.setStatus("PAID");

Correct Approach

// ✅ 正确: 回调金额必须与本地订单金额严格一致
int callbackAmountFen = payData.get("amount").get("total").asInt();
int expectedAmountFen = order.getTotalAmount()
    .multiply(BigDecimal.valueOf(100)).intValue();

if (callbackAmountFen != expectedAmountFen) {
    log.error("金额不一致: callback={}, expected={}, orderId={}",
        callbackAmountFen, expectedAmountFen, order.getId());
    // 标记异常,不入账
    paymentRecord.setReconcileStatus("MISMATCHED");
    paymentRecord.setReconcileErrorMessage("金额不一致");
    return;
}

Checklist

  • Is the callback amount strictly compared with the local order amount payable
  • Is the basis for amount comparison consistent with the order placement request (the same field)
  • If the amounts are inconsistent, is crediting rejected and an exception recorded
  • Is there an encapsulated method that explicitly defines the "WeChat Pay amount basis" (to prevent future changes from causing the basis to diverge)

Pitfall #4: Callbacks and Reconciliation Use Different Validation Logic

Scenario: The callback flow has complete validation, but the "manual reconciliation" flow skips part of the validation

Root Cause

Reconciliation supplementary orders and callback handling are essentially both "confirming payment success". If they go through different verification logic, it is easy to leave a security gap in the reconciliation chain.

Incorrect example

// ❌ 错误: 回调有完整校验
public void handleCallback(JsonNode payData, TradeOrder order) {
    validateAmount(payData, order);
    validateMchId(payData, config);
    validateAppId(payData, config);
    processPayment(order, transactionId);
}

// ❌ 错误: 对账直接补单,跳过校验
public void reconcile(PaymentRecord record) {
    JsonNode queryResult = queryWechatOrder(outTradeNo);
    if ("SUCCESS".equals(tradeState)) {
        processPayment(order, transactionId);  // 没有校验金额/商户号
    }
}

Correct approach

// ✅ 正确: 抽取统一校验函数,回调和对账都复用
public void validatePaymentResult(
    JsonNode payData, TradeOrder order, TenantConfig config) {
    // 1. out_trade_no 一致
    // 2. transaction_id 非空
    // 3. appid 一致
    // 4. mchid 一致
    // 5. 金额一致
    // 任何一项不通过都抛异常
}

// 回调链路
public void handleCallback(JsonNode payData, TradeOrder order, TenantConfig config) {
    validatePaymentResult(payData, order, config);
    processPayment(order, transactionId);
}

// 对账链路
public void reconcile(PaymentRecord record) {
    JsonNode queryResult = queryWechatOrder(outTradeNo);
    validatePaymentResult(queryResult, order, config);  // 同一套校验
    processPayment(order, transactionId);
}

Checklist

  • Do callbacks and reconciliation reuse the same set of payment result verification logic
  • Does reconciliation supplementary order creation also verify the amount, merchant number, and AppID
  • Is "manually clicking the reconciliation button" itself not considered a basis for payment success
  • Does reconciliation only trust the real response from the payment platform's order query API

Pitfall #5: Placeholder/TODO implementation goes live

Scenario: Critical logic such as payment queries and reconciliation uses placeholder implementations, but goes live without being discovered

Incorrect example

// ❌ 错误: 占位实现,返回假数据
private JsonNode queryWechatOrderStatus(PaymentRecord record) {
    // TODO: 接入微信支付订单查询 V3 API
    log.warn("微信订单查询 API 待实现: orderId={}", record.getOrderId());
    return objectMapper.createObjectNode()
        .put("trade_state", "NOTPAY")
        .put("trade_state_desc", "未支付(占位)");
}

Correct approach

// ✅ 正确: 占位实现必须明确失败,不能返回看似正常的假数据
private JsonNode queryWechatOrderStatus(PaymentRecord record) {
    throw new UnsupportedOperationException(
        "微信订单查询 API 未实现,请先完成接入");
}

Checklist

  • Will payment-related TODO/placeholder implementations throw exceptions rather than return fake data
  • Are there logs or monitoring that can detect when placeholder logic is triggered
  • Have all TODOs in the payment chain been checked before going live

Checklist (payment callback security)

Signature verification and anti-forgery:

  • Does the callback verify the platform signature
  • Does signature verification use the platform public key/certificate (not the merchant private key)
  • Is platform certificate rotation supported (matching by serial)

Anti-replay:

  • Is the timestamp window verified
  • Is idempotency implemented by transaction_id
  • Is the order state machine checked

Business verification:

  • Is the amount strictly consistent with the local order
  • Is out_trade_no consistent with the local order number
  • Are appid / mchid consistent with the tenant configuration
  • Do callbacks and reconciliation reuse the same set of verification

Implementation completeness:

  • Does the payment chain have placeholder/TODO implementations
  • Do placeholder implementations fail explicitly (rather than return fake data)

Scope of application

  • WeChat Pay API v3
  • Alipay Open Platform
  • Stripe Webhooks
  • PayPal IPN/Webhooks
  • Other third-party payment/webhook notifications

Rule traceability

> 📋 本回复遵循:`cc-payment-callback-safety` - [章节名]