NueForm 的每个 webhook 请求都包含一个加密签名,让您验证请求的真实性和完整性。您应始终在处理 webhook 数据之前验证此签名。
签名工作原理
当 NueForm 分发 webhook 时,它会:
- 将负载序列化为 JSON 字符串。
- 使用您的 webhook 密钥作为密钥,计算该 JSON 字符串的 HMAC-SHA256 哈希。
- 将哈希编码为小写十六进制字符串。
- 在
X-NueForm-SignatureHTTP 头中发送十六进制摘要。
在您这端,您对原始请求体执行相同的计算,并将结果与头中的值进行比较。如果匹配,请求是真实的。
签名头
X-NueForm-Signature: 5d41402abc4b2a76b9719d911017c592a3f6e7d4b9c1d5e8f2a7b3c6d9e0f1a2
头的值是一个 64 字符的十六进制字符串(HMAC-SHA256 的输出)。
您的 Webhook 密钥
您的 webhook 密钥是从 32 个随机字节生成的 64 字符十六进制字符串。它对您的账户唯一,在所有 webhook 端点(单表单和全局)之间共享。
获取您的密钥
通过 API 获取当前密钥:
curl https://app.nueform.com/api/v1/webhooks/secret \
-H "Authorization: Bearer nf_your_api_key"
响应:
{
"secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
}
如果您还没有密钥,NueForm 会在首次请求时自动生成一个。
重新生成密钥
如果您的密钥被泄露,请立即重新生成:
curl -X POST https://app.nueform.com/api/v1/webhooks/secret/regenerate \
-H "Authorization: Bearer nf_your_api_key"
重新生成密钥会立即使旧密钥失效。此后所有的 webhook 传递都将使用新密钥。请在重新生成之前或之后立即更新您的验证代码,以避免拒绝有效的 webhooks。
验证代码示例
Node.js
import crypto from 'crypto';
function verifyWebhookSignature(rawBody, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// 使用时间安全比较防止计时攻击
const a = Buffer.from(signature, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
// Express.js 示例
app.post('/webhooks/nueform', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-nueform-signature'];
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.NUEFORM_WEBHOOK_SECRET)) {
console.error('Invalid webhook signature');
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(rawBody);
console.log('Verified webhook:', payload.event, payload.responseId);
// 异步处理 webhook
processWebhookAsync(payload);
res.status(200).send('OK');
});
使用 Express.js 时,您必须使用 express.raw() 或 express.text() 来访问原始请求体。如果使用 express.json(),请求体将被解析并重新序列化,这可能产生与签名时不同的字符串——导致验证失败。
Python
import hashlib
import hmac
def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# 使用时间安全比较
return hmac.compare_digest(expected, signature)
# Flask 示例
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhooks/nueform', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-NueForm-Signature', '')
raw_body = request.get_data()
if not verify_webhook_signature(raw_body, signature, WEBHOOK_SECRET):
abort(401, 'Invalid signature')
payload = request.get_json()
print(f"Verified webhook: {payload['event']} {payload['responseId']}")
# 异步处理(例如加入 Celery 队列)
process_webhook.delay(payload)
return 'OK', 200
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
)
func verifyWebhookSignature(body []byte, signature string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal 执行常量时间比较
return hmac.Equal([]byte(expected), []byte(signature))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
signature := r.Header.Get("X-NueForm-Signature")
if !verifyWebhookSignature(body, signature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// 处理 webhook 负载
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
PHP
<?php
function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
// 使用时间安全比较
return hash_equals($expected, $signature);
}
// 使用方法
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_NUEFORM_SIGNATURE'] ?? '';
$secret = getenv('NUEFORM_WEBHOOK_SECRET');
if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
http_response_code(401);
echo 'Invalid signature';
exit;
}
$payload = json_decode($rawBody, true);
error_log("Verified webhook: {$payload['event']} {$payload['responseId']}");
// 处理 webhook
processWebhook($payload);
http_response_code(200);
echo 'OK';
Ruby
require 'openssl'
require 'json'
def verify_webhook_signature(raw_body, signature, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
# 使用时间安全比较
Rack::Utils.secure_compare(expected, signature)
end
# Sinatra 示例
post '/webhooks/nueform' do
raw_body = request.body.read
signature = request.env['HTTP_X_NUEFORM_SIGNATURE'] || ''
unless verify_webhook_signature(raw_body, signature, ENV['NUEFORM_WEBHOOK_SECRET'])
halt 401, 'Invalid signature'
end
payload = JSON.parse(raw_body)
logger.info "Verified webhook: #{payload['event']} #{payload['responseId']}"
# 异步处理
WebhookProcessorJob.perform_async(payload)
status 200
body 'OK'
end
时间安全比较
以上所有代码示例在检查签名时都使用时间安全比较(也称为常量时间比较)。这是防止计时攻击的安全最佳实践。
计时攻击通过测量字符串比较花费的时间来工作。简单的 == 比较在发现第一个不匹配字符时就返回 false,因此攻击者可以通过测量响应延迟逐个字符了解签名。
时间安全比较函数无论匹配多少字符都花费相同的时间,使此攻击变得不可行。
| 语言 | 函数 |
|---|---|
| Node.js | crypto.timingSafeEqual() |
| Python | hmac.compare_digest() |
| Go | hmac.Equal() |
| PHP | hash_equals() |
| Ruby | Rack::Utils.secure_compare() |
永远不要使用 ===、== 或 .equals() 来比较 HMAC 签名。始终使用您所用语言的内置时间安全比较函数。
验证失败时的处理
如果签名验证失败,您的端点应:
- **返回
401 Unauthorized状态码。**不要处理负载。 - 记录失败以便调试。包括请求 IP、时间戳和(可选的)收到的签名。
- 不要在错误消息或日志中暴露您的密钥。
- 排查常见原因:
- **密钥错误。**确保您使用的是当前的 webhook 密钥。如果您最近重新生成了密钥,请更新验证代码。
- **请求体转换。**确保您验证的是原始请求体,而不是解析后重新序列化的版本。在验证代码运行之前解析 JSON 的中间件是最常见的失败原因。
- **编码问题。**原始请求体必须作为 UTF-8 字节处理。确保您的框架不会应用意外的编码转换。
- **代理修改。**如果反向代理(例如 Cloudflare、nginx)修改了请求体,签名将不匹配。配置代理使其原样传递请求体。
故障排除检查清单
| 问题 | 解决方案 |
|---|---|
| 签名从不匹配 | 验证您读取的是原始请求体字节,而不是解析后的 JSON 对象 |
| 签名突然不匹配 | 检查 webhook 密钥是否被重新生成 |
| 签名在本地匹配但在生产环境中不匹配 | 检查代理或 CDN 的请求体转换 |
X-NueForm-Signature 头缺失 | 确保您的框架保留自定义头(某些会去除 X- 前缀的头) |