PAYATHON 2026

BitPay API notifications 是否存在安全风险?

支付阿杰

结论

需要担心。未经认证的 notification 只能说明状态可能发生了变化,不能作为付款已确认的依据。

建议按以下方式处理:

  1. 只从通知中读取 invoice ID。
  2. 使用服务器端保存的 BitPay 凭证,通过官方 API 查询对应的 invoice。
  3. 根据 API 返回的状态、金额和币种等信息更新订单。
  4. 如果当前使用的 BitPay API 明确支持 webhook 签名,先验证签名。对于关键订单,即使签名验证通过,也可以再次查询 API。
  5. 采用幂等处理、状态机和重放防护,避免重复或乱序通知导致错误。

检查来源 IP、隐藏 notification URL 可以减少无效请求,但不能替代身份验证。

直接相信 notification 有什么风险

只要 notification URL 可以访问,攻击者就可能构造类似的请求:

{
  "id": "123BitPayInvoiceID",
  "status": "confirmed"
}

如果服务端只根据 id 查找订单,并直接将订单状态改为 confirmed,伪造请求就可能绕过真实的付款流程。

通知中的以下字段都不能直接信任:

  • status
  • price
  • currency
  • btcPaid
  • posData
  • 通知中提供的 url
  • 任何嵌套对象中的付款状态

尤其不要请求通知负载中携带的 url。攻击者可能篡改这个字段,引入 SSRF 风险。查询地址应由服务端使用固定的 BitPay API 基础地址和经过校验的 invoice ID 构造。

推荐的处理流程

收到 notification 后,按以下顺序处理:

  1. 只接受 POST 请求。
  2. 限制请求体大小并解析 JSON。
  3. 检查 invoice ID 的类型、长度和格式。
  4. 根据 invoice ID 查找本地订单。
  5. 如果当前 API 版本支持 webhook 签名,验证签名和时间戳。
  6. 使用服务端凭证向 BitPay API 查询 invoice。
  7. 核对 invoice ID、金额、币种,以及商户自己的订单关联信息。
  8. 根据 API 返回的状态执行允许的状态迁移。
  9. 使用数据库事务和幂等条件更新订单。
  10. 返回成功响应,避免 BitPay 反复重试。

核心原则很简单:notification 只负责触发查询,经过认证的 BitPay API 查询结果才用于确认事实。

PHP 示例

不同版本的 BitPay API 可能使用不同的认证方式、URL 和响应结构。下面的代码只展示安全处理思路,其中的 API 地址和认证部分必须替换为当前 BitPay SDK 或官方文档规定的实现。

<?php

declare(strict_types=1);

const MAX_NOTIFICATION_SIZE = 64 * 1024;

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit;
}

$contentLength = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($contentLength > MAX_NOTIFICATION_SIZE) {
    http_response_code(413);
    exit;
}

$rawBody = file_get_contents('php://input', false, null, 0, MAX_NOTIFICATION_SIZE + 1);

if ($rawBody === false || strlen($rawBody) > MAX_NOTIFICATION_SIZE) {
    http_response_code(400);
    exit;
}

try {
    $notification = json_decode($rawBody, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    http_response_code(400);
    exit;
}

$invoiceId = $notification['id'] ?? null;

if (
    !is_string($invoiceId) ||
    $invoiceId === '' ||
    strlen($invoiceId) > 128 ||
    !preg_match('/^[A-Za-z0-9_-]+$/', $invoiceId)
) {
    http_response_code(400);
    exit;
}

/*
 * 不要在这里信任:
 *
 * $notification['status']
 * $notification['price']
 * $notification['currency']
 * $notification['btcPaid']
 * $notification['posData']
 * $notification['url']
 *
 * notification 只用于通知系统重新查询 invoice。
 */

try {
    $invoice = fetchBitPayInvoice($invoiceId);
    verifyAndApplyInvoice($invoiceId, $invoice);

    http_response_code(200);
    echo 'OK';
} catch (Throwable $e) {
    // 记录内部日志,但不要向请求方泄露凭证、堆栈或订单信息。
    error_log('BitPay notification processing failed: ' . $e->getMessage());

    // 临时查询失败时返回非 2xx,是否触发重试取决于当前 BitPay API 的通知机制。
    http_response_code(503);
}

查询函数可以使用官方 SDK 实现。如果使用 HTTP 客户端,结构大致如下:

<?php

function fetchBitPayInvoice(string $invoiceId): array
{
    $baseUrl = 'https://bitpay.com/invoices/';
    $url = $baseUrl . rawurlencode($invoiceId);

    $ch = curl_init($url);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 15,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_HTTPHEADER => [
            'Accept: application/json',
            // 按当前 BitPay API 的要求添加认证信息。
        ],
    ]);

    $body = curl_exec($ch);

    if ($body === false) {
        $message = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException('BitPay request failed: ' . $message);
    }

    $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($statusCode !== 200) {
        throw new RuntimeException(
            'Unexpected BitPay response: HTTP ' . $statusCode
        );
    }

    try {
        $response = json_decode($body, true, 32, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        throw new RuntimeException('Invalid BitPay response', 0, $e);
    }

    /*
     * 某些 API 版本会把 invoice 放在 data 等包装字段中。
     * 应按实际版本的响应结构取值。
     */
    $invoice = $response['data'] ?? $response;

    if (!is_array($invoice)) {
        throw new RuntimeException('Missing invoice data');
    }

    return $invoice;
}

更新订单前,还需要核对业务数据:

<?php

function verifyAndApplyInvoice(string $expectedInvoiceId, array $invoice): void
{
    $actualInvoiceId = $invoice['id'] ?? null;
    $status = $invoice['status'] ?? null;
    $price = $invoice['price'] ?? null;
    $currency = $invoice['currency'] ?? null;

    if (!is_string($actualInvoiceId) ||
        !hash_equals($expectedInvoiceId, $actualInvoiceId)) {
        throw new RuntimeException('Invoice ID mismatch');
    }

    $payment = findPaymentByBitPayInvoiceId($expectedInvoiceId);

    if ($payment === null) {
        throw new RuntimeException('Unknown invoice');
    }

    /*
     * 金额不要使用 float 直接比较。
     * 应转换为最小货币单位,或使用 decimal/BCMath。
     */
    if (!sameMoneyAmount($payment['amount'], $price)) {
        throw new RuntimeException('Invoice amount mismatch');
    }

    if (!is_string($currency) ||
        strtoupper($currency) !== strtoupper($payment['currency'])) {
        throw new RuntimeException('Invoice currency mismatch');
    }

    /*
     * 具体可接受的最终状态取决于当前 BitPay API 版本和业务风险。
     * 不要假定所有名为 paid、confirmed 或 complete 的状态含义完全相同。
     */
    if ($status !== 'confirmed') {
        return;
    }

    markPaymentConfirmedIdempotently(
        $payment['id'],
        $expectedInvoiceId
    );
}

数据库更新应附带条件,保证重复通知不会导致重复发货:

UPDATE payments
SET status = 'confirmed',
    confirmed_at = CURRENT_TIMESTAMP
WHERE id = :payment_id
  AND bitpay_invoice_id = :invoice_id
  AND status = 'pending';

更新后需要检查受影响的行数。只有首次从 pending 迁移到 confirmed 的请求,才能触发发货、充值或开通服务。更稳妥的系统可以采用事务性 outbox,将付款状态更新和后续业务事件写入同一个数据库事务。

Webhook 签名优先于来源判断

如果当前使用的 BitPay API 或 SDK 提供 webhook 签名,应严格按照对应版本的官方规则验证:

  • 使用原始请求体,不要使用重新序列化后的 JSON。
  • 验证签名算法和密钥。
  • 如果协议提供时间戳,限制可接受的时间窗口。
  • 如果协议提供事件 ID,记录该 ID 并拒绝重复事件。
  • 使用安全的恒定时间比较函数,例如 hash_equals()。
  • 不要自行猜测签名 Header、拼接格式或算法。

不同 BitPay 产品和 API 版本的通知验证机制可能不同。如果当前文档没有明确说明通知带有可验证签名,就不能假设某个 Header 具有认证作用。此时应在收到通知后重新查询 API。

IP 白名单和秘密 URL 是否有用

它们可以作为辅助防护,但不能成为唯一的安全边界。

IP 白名单有以下局限:

  • 服务商的出口 IP 可能发生变化。
  • 代理和部署架构可能影响来源地址。
  • 错误地信任 X-Forwarded-For 可能被绕过。
  • 它只能说明网络来源大致符合预期,无法防止消息被重放。

难以猜测的 URL 本质上是一种较弱的共享秘密。它可能出现在访问日志、错误报告、监控平台或代理配置中。一旦 URL 泄露,攻击者仍然可以伪造请求。

HTTPS、IP 限制、随机路径和速率限制可以同时使用,但订单状态最终仍应由签名验证或经过认证的 API 查询结果决定。

还需要注意的细节

不要把客户端提供的订单标识作为唯一关联依据

创建 invoice 时,应在数据库中保存 BitPay invoice ID 与本地订单 ID 的绑定关系。收到通知后,通过这层绑定查找订单。

即使 posData 中包含 paymentID 或 orderID,未经验证也不能直接用它确认另一笔订单。

处理重复和乱序通知

Webhook 通常采用至少一次投递,同一通知可能重复出现,状态通知也可能乱序到达。状态机应只允许合法迁移,例如:

pending -> paid -> confirmed -> complete

订单一旦进入最终成功状态,就不应因迟到的旧通知退回 paid 或 pending。退款、争议和异常状态需要按照业务规则单独处理。

不要直接用浮点数比较金额

PHP 的 float 不适合精确比较货币金额。可以将法币金额转换为最小单位,例如美分,也可以使用数据库 DECIMAL、BCMath 等十进制计算方式。

<?php

function sameMoneyAmount(string $expected, mixed $actual): bool
{
    if (!is_string($actual) && !is_int($actual) && !is_float($actual)) {
        return false;
    }

    return bccomp($expected, (string) $actual, 2) === 0;
}

实际使用的小数位数应根据币种确定,不能假定所有币种都有两位小数。

将网络查询与发货操作解耦

接口可以先验证并记录事件,再由队列任务查询 invoice 和更新订单。这样可以避免网络超时长时间占用 notification 请求,也更便于重试。

无论采用同步还是异步处理,未经验证的通知都不能触发发货、充值、提现或权限开通。

最终建议

应将 BitPay notification 视为不可信输入。如果官方提供签名验证机制,先验证签名。无法验证签名或订单价值较高时,使用服务端凭证重新查询 invoice,并核对 ID、金额、币种和状态。

来源 IP 和秘密 URL 只能提供额外防护。订单状态应由经过认证、并且与本地订单严格匹配的 BitPay API 响应决定。

备注:内容仅供参考。