إنتقل إلى المحتوى الرئيسي

التحقق من التواقيع

كل 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 دقائق

ثلاث قواعد للأمان:

  1. استخدم بايتات الجسم الخام للـ SHA-256 الداخلي. إعادة تسلسل JSON يعطي تجزئة مختلفة.
  2. قارن التوقيع بزمن ثابت. == العادي يُسرّب معلومات توقيت تساعد التخمين العنيف.
  3. ارفض الطوابع القديمة (>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 على أي حال

هذا النمط يعني: حتى إن أعدنا النداء، تُنفّذ العمل مرة وتردّ بسرعة.