التحقق من التواقيع
كل webhook تُرسله DZBuild يحمل توقيعًا HMAC-SHA256. معالجك يجب أن يتحقّق منه قبل الوثوق بالجسم.
الوصفة
expected = hex( hmac_sha256( WEBHOOK_SECRET, X-DZ-Timestamp + "." + sha256_hex(raw_body) ) )
if (!constant_time_equal(expected, X-DZ-Signature)) reject 401
if (abs(now - X-DZ-Timestamp) > 300) reject 401 # نافذة إعادة ±5 دقائق
ثلاث قواعد للأمان:
- استخدم بايتات الجسم الخام للـ SHA-256 الداخلي. إعادة تسلسل JSON يعطي تجزئة مختلفة.
- قارن التوقيع بزمن ثابت.
==العادي يُسرّب معلومات توقيت تساعد التخمين العنيف. - ارفض الطوابع القديمة (>5 دقائق من ساعة خادمك). شغّل NTP.
الكود
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.DZBUILD_WEBHOOK_SECRET;
// مهم: التقط الجسم الخام لـ HMAC، منفصلًا عن JSON المحلَّل.
app.post('/webhooks/dzbuild',
express.raw({ type: 'application/json' }),
(req, res) => {
const ts = req.get('X-DZ-Timestamp');
const sig = req.get('X-DZ-Signature');
if (!ts || !sig) return res.status(401).end();
if (Math.abs(Math.floor(Date.now()/1000) - Number(ts)) > 300) {
return res.status(401).end();
}
const bodyHash = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(`${ts}.${bodyHash}`)
.digest('hex');
if (expected.length !== sig.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
console.log('verified', event.event, event.data);
res.status(200).end();
});
app.listen(3000);
PHP (raw)
<?php
$secret = getenv('DZBUILD_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_DZ_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_DZ_SIGNATURE'] ?? '';
if ($ts === '' || $sig === '') { http_response_code(401); exit; }
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; }
$bodyHash = hash('sha256', $body);
$expected = hash_hmac('sha256', $ts . '.' . $bodyHash, $secret);
if (!hash_equals($expected, strtolower($sig))) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
في Laravel استعمل route middleware أو controller يقرأ $request->getContent() للجسم الخام. عطّل CSRF على مسار webhook.
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['DZBUILD_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/dzbuild')
def receive():
ts = request.headers.get('X-DZ-Timestamp', '')
sig = request.headers.get('X-DZ-Signature', '')
if not ts or not sig: abort(401)
if abs(int(time.time()) - int(ts)) > 300: abort(401)
body = request.get_data()
body_hash = hashlib.sha256(body).hexdigest()
expected = hmac.new(WEBHOOK_SECRET, f"{ts}.{body_hash}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig.lower()): abort(401)
event = request.get_json()
print('verified', event['event'], event['data'])
return '', 200
Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("DZBUILD_WEBHOOK_SECRET"))
func receive(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "read", 400); return }
ts := r.Header.Get("X-DZ-Timestamp")
sig := r.Header.Get("X-DZ-Signature")
if ts == "" || sig == "" { http.Error(w, "no sig", 401); return }
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil { http.Error(w, "ts", 401); return }
if abs(time.Now().Unix() - tsInt) > 300 { http.Error(w, "stale", 401); return }
h := sha256.Sum256(body)
bodyHash := hex.EncodeToString(h[:])
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "." + bodyHash))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad sig", 401); return
}
w.WriteHeader(http.StatusOK)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
أخطاء شائعة
| الخطأ | العَرَض | الحل |
|---|---|---|
| إعادة تسلسل JSON قبل التجزئة | التوقيع لا يطابق أبدًا | استخدم بايتات الجسم الخام — انظر ملاحظات الإطار أعلاه |
| انحراف ساعة الخادم | رفض "Timestamp out of window" | شغّل NTP، تحقّق من timedatectl status على Linux |
المقارنة بـ == بدل زمن ثابت | ثغرة timing-attack دقيقة | استعمل crypto.timingSafeEqual / hmac.compare_digest / hash_equals |
| تسجيل السر على القرص | السر ينتهي في ملفات السجل | لا تُسجّله؛ استعمل secrets store؛ دوّره إن تسرّب |
| الردّ بـ 200 فورًا والمعالجة لاحقًا | فقدان أحداث عند تحطّم العامل | إما احفظ في طابورك ثم ack، أو نفّذ العمل بشكل متزامن وردّ آخر شيء |
Idempotency من جانبك
نفس delivery_id قد يصل أكثر من مرة إن ظنّت طبقة التسليم لدينا أن المحاولة الأولى فشلت (انقسام شبكة، نقطتك بطيئة). أزل التكرار من جانب الاستقبال:
INSERT INTO webhook_log (delivery_id, event, body) VALUES (?, ?, ?);
-- أمسك انتهاك UNIQUE على delivery_id → معالَج بالفعل، أعد 200 على أي حال
هذا النمط يعني: حتى إن أعدنا النداء، تُنفّذ العمل مرة وتردّ بسرعة.