BitPay API notifications 是否存在安全风险?
结论
需要担心。未经认证的 notification 只能说明状态可能发生了变化,不能作为付款已确认的依据。
建议按以下方式处理:
- 只从通知中读取 invoice ID。
- 使用服务器端保存的 BitPay 凭证,通过官方 API 查询对应的 invoice。
- 根据 API 返回的状态、金额和币种等信息更新订单。
- 如果当前使用的 BitPay API 明确支持 webhook 签名,先验证签名。对于关键订单,即使签名验证通过,也可以再次查询 API。
- 采用幂等处理、状态机和重放防护,避免重复或乱序通知导致错误。
检查来源 IP、隐藏 notification URL 可以减少无效请求,但不能替代身份验证。
直接相信 notification 有什么风险
只要 notification URL 可以访问,攻击者就可能构造类似的请求:
{
"id": "123BitPayInvoiceID",
"status": "confirmed"
}
如果服务端只根据 id 查找订单,并直接将订单状态改为 confirmed,伪造请求就可能绕过真实的付款流程。
通知中的以下字段都不能直接信任:
statuspricecurrencybtcPaidposData- 通知中提供的
url - 任何嵌套对象中的付款状态
尤其不要请求通知负载中携带的 url。攻击者可能篡改这个字段,引入 SSRF 风险。查询地址应由服务端使用固定的 BitPay API 基础地址和经过校验的 invoice ID 构造。
推荐的处理流程
收到 notification 后,按以下顺序处理:
- 只接受
POST请求。 - 限制请求体大小并解析 JSON。
- 检查 invoice ID 的类型、长度和格式。
- 根据 invoice ID 查找本地订单。
- 如果当前 API 版本支持 webhook 签名,验证签名和时间戳。
- 使用服务端凭证向 BitPay API 查询 invoice。
- 核对 invoice ID、金额、币种,以及商户自己的订单关联信息。
- 根据 API 返回的状态执行允许的状态迁移。
- 使用数据库事务和幂等条件更新订单。
- 返回成功响应,避免 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 响应决定。
备注:内容仅供参考。
