PAYATHON 2026

Node.js Express 如何获取并验证 request Referer

支付小周

结论

不能根据 Referer、Origin 或 User-Agent 判断支付通知是否来自指定域名。这些请求头并非必须发送,服务端程序既可以省略,也可以伪造。

正确的处理方式是按照支付网关文档验证通知签名,一般使用 HMAC 或公钥签名。验证通过后,再调用支付网关的服务端 API 查询交易并核对订单状态。IP 白名单和来源请求头只能用于辅助检查。

为什么没有 Referer 和 Origin

Referer 通常由浏览器在页面跳转或加载资源时发送。支付网关回调大多是服务器之间直接发起的 POST 请求,不经过用户浏览器,所以没有这个字段很正常。

即使请求来自浏览器,Referer 也可能受 Referrer-Policy、隐私设置、安全软件或重定向影响而被删除。HTTP 标准中的字段名一直是拼写有误的 Referer,而不是 Referrer。

Origin 也不是所有 POST 请求都必须携带的字段。它主要用于浏览器的同源策略和 CORS 安全机制,普通 HTTP 客户端可以不发送。

还需要注意以下几点:

  • Host: my.foo.com 只能说明请求的目标是你的服务器,不能证明发送方的身份。
  • User-Agent 可以任意填写。
  • 只有当请求经过可信反向代理,而且代理会覆盖外部传入的值时,X-Forwarded-For 才可信。
  • HTTPS 能保护传输过程并验证接收方证书,但不会自动向应用证明发送方属于某个域名。

如果只需要读取这些可选的来源头,可以这样写:

const referer = req.get('referer');
const origin = req.get('origin');

不要将它们用于支付鉴权。

推荐的验证流程

收到支付通知后,按以下顺序处理:

  1. 保存未经解析和重新序列化的原始请求体。
  2. 读取支付网关提供的签名和时间戳请求头。
  3. 使用共享密钥计算 HMAC,或使用网关公钥验证数字签名。
  4. 使用恒定时间比较方法检查签名。
  5. 检查时间戳并拒绝过期请求。
  6. 使用事件 ID 或交易 ID 避免重复处理。
  7. 主动调用支付网关的服务端 API,核对订单号、金额、币种、商户号和支付状态。
  8. 所有验证都通过后再更新本地订单,并确保处理逻辑是幂等的。

签名算法、待签名字符串、请求头名称和编码方式必须以支付网关的官方文档为准,不能自行推断。

Express HMAC 验证示例

以下示例使用虚构的 X-Payment-Timestamp 和 X-Payment-Signature 展示通用结构。假设网关规定待签名内容为:

timestamp + "." + rawRequestBody

签名算法为 HMAC-SHA256,结果采用十六进制编码。实际接入时,需要替换成支付网关规定的格式。

const express = require('express');
const crypto = require('crypto');

const app = express();

function safeHexEqual(receivedHex, expectedHex) {
  if (
    !/^[0-9a-f]+$/i.test(receivedHex) ||
    receivedHex.length % 2 !== 0
  ) {
    return false;
  }

  const received = Buffer.from(receivedHex, 'hex');
  const expected = Buffer.from(expectedHex, 'hex');

  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}

app.post(
  '/payment/notify/',
  express.raw({ type: 'application/x-www-form-urlencoded' }),
  async (req, res) => {
    const timestampHeader = req.get('x-payment-timestamp');
    const receivedSignature = req.get('x-payment-signature');

    if (!timestampHeader || !receivedSignature) {
      return res.status(401).send('Missing signature');
    }

    const timestamp = Number(timestampHeader);

    if (!Number.isSafeInteger(timestamp)) {
      return res.status(401).send('Invalid timestamp');
    }

    const now = Math.floor(Date.now() / 1000);
    const allowedSkewSeconds = 300;

    if (Math.abs(now - timestamp) > allowedSkewSeconds) {
      return res.status(401).send('Expired request');
    }

    const secret = process.env.PAYMENT_WEBHOOK_SECRET;

    if (!secret) {
      console.error('PAYMENT_WEBHOOK_SECRET is not configured');
      return res.sendStatus(500);
    }

    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(timestampHeader)
      .update('.')
      .update(req.body) // Buffer:保持原始请求字节
      .digest('hex');

    if (!safeHexEqual(receivedSignature, expectedSignature)) {
      return res.status(401).send('Invalid signature');
    }

    // 必须在签名验证之后解析请求体。
    const form = new URLSearchParams(req.body.toString('utf8'));

    const transactionId = form.get('transaction_id');
    const orderId = form.get('order_id');
    const amount = form.get('amount');
    const currency = form.get('currency');

    if (!transactionId || !orderId) {
      return res.status(400).send('Invalid payload');
    }

    /*
     * 接下来应当:
     *
     * 1. 检查 transactionId 是否已经处理;
     * 2. 调用支付网关服务端 API 查询交易;
     * 3. 核对商户号、orderId、amount、currency 和最终状态;
     * 4. 在数据库事务中幂等地更新订单;
     * 5. 记录 transactionId,避免重复入账。
     */

    return res.sendStatus(200);
  }
);

不要先将表单解析成对象,再使用 JSON.stringify() 或重新拼接表单来计算签名。字段顺序、空格和百分号编码等细微变化都可能造成签名不一致。签名通常需要根据原始请求字节计算。

如果支付网关不提供签名

如果网关不提供签名、客户端证书或其他共享凭据,仅凭一次 HTTP 请求无法可靠证明发送方来自某个域名。

更稳妥的处理方法是:

  • 将通知中的交易 ID 当作不可信输入。
  • 使用自己的服务端凭据调用支付网关 API。
  • 从网关获取真实交易记录。
  • 核对商户号、订单号、金额、币种和状态。
  • 完成查询确认之前,不要将订单更新为已支付。

如果网关支持,还可以启用 mTLS。只有网关公布了稳定的出口 IP 范围,才适合增加 IP 白名单。IP 白名单属于纵深防御措施,不能代替签名验证和交易查询。

请求经过 Nginx 等反向代理时,还要确认代理会删除外部请求自带的 X-Forwarded-For,然后重新生成该字段,否则攻击者可以自行填写这个请求头。Express 的 trust proxy 也只能配置为实际可信的代理范围,不能仅因为请求中存在 X-Forwarded-For 就信任它。

备注:内容仅供参考。