التحقق من webhooks واتساب: لماذا يهمّ الـ body الخام
Meta توقّع البايتات التي أرسلتها بالضبط. إذا تحققت من التوقيع بعد أن حلّل الإطار الطلب فأنت تفحص رسالة مختلفة. أين ينتمي التحقق، ولماذا حصل على خدمة خاصة به.
كل webhook ترسله Meta — رسالة واتساب، تعليق على إنستغرام، إشعار تسليم — يصل ومعه header يثبت مصدره. تحقَّق منه بطريقة خاطئة ويحدث أحد أمرين: ترفض عملاء حقيقيين، أو تقبل أي شخص يعثر على عنوان الـ endpoint.
والفشلان صامتان. ولهذا يستحق الأمر أن يُضبط مرة واحدة، في مكان واحد.
ما الذي توقّعه Meta فعلاً
الـ header هو X-Hub-Signature-256، وقيمته sha256= متبوعة بـ digest بصيغة hex: أي HMAC-SHA256 لحمولة الطلب، مفتاحه الـ app secret الخاص بتطبيقك.
الكلمة المهمة هي الحمولة. Meta توقّع البايتات التي أرسلتها بالضبط، لا البيانات التي تصفها تلك البايتات. قد يحمل مستندا JSON البيانات نفسها ويختلفان بايتاً ببايت — في ترتيب المفاتيح، وفي المسافات، وفي كون الشرطة المائلة مهرَّبة أم لا، وفي كون الحرف غير الـ ASCII مكتوباً كما هو أم بصيغة \u. ووثائق Meta نفسها تنبّه إلى أن التوقيع يُحسب على الصيغة المهرَّبة.
فللتحقق مُدخَل صحيح واحد فقط: الـ body كما وصل، قبل أن يحلّله أي شيء.
الخطأ الذي ينجح في اختباراته
هذه هي النسخة التي تُكتب أولاً:
// تبدو معقولة. لكنها تتحقق من رسالة مختلفة.
const expected = createHmac('sha256', appSecret)
.update(JSON.stringify(request.body))
.digest('hex');عند تنفيذ هذا السطر يكون الإطار قد حلّل الـ body وسلّمك كائناً. ثم تكتب JSON.stringify مستنداً جديداً من ذلك الكائن، فتقارن توقيع Meta ببايتات لم ترسلها Meta قط.
وخطورته أنه قد ينجح. حمولة قصيرة بقيم ASCII بسيطة قد تعود إلى البايتات نفسها، فينجح الاختبار الأول ويُشحن الكود. ثم يتوقف التطابق يوم تحتوي الحمولة على شيء يكتبه المُسلسِلان بشكلين مختلفين. وفي منتج عربي أولاً، ذلك اليوم هو أول عميل حقيقي: الرسالة العربية تصل مهرَّبة، والنسخة المعاد تسلسلها تكتب الحروف كما هي.
التحقق من البايتات التي أُرسلت
احتفظ بالـ body الخام، وتحقق منه هو. في Fastify يعني هذا أن تطلب من محلّل JSON إعطاءك buffer وتحلّله بنفسك:
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(request, body, done) => {
// يُحفظ للتحقق من التوقيع؛ والكائن المحلَّل لكل ما عدا ذلك.
request.rawBody = body as Buffer;
try {
done(null, JSON.parse(body.toString('utf8')));
} catch (error) {
done(error as Error);
}
},
);والتحقق نفسه قصير:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function isFromMeta(rawBody: Buffer, header: string | undefined, appSecret: string) {
if (!header?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', appSecret).update(rawBody).digest();
const received = Buffer.from(header.slice('sha256='.length), 'hex');
// timingSafeEqual ترمي خطأ عند اختلاف الطول، فيُستبعد ذلك أولاً.
return received.length === expected.length && timingSafeEqual(received, expected);
}تفصيلان هنا ليسا للزينة. المقارنة ثابتة الزمن، لأن المقارنة التي تتوقف عند أول بايت خاطئ تخبر المهاجم كم بايتاً أصاب. والـ header المفقود أو المشوَّه رفضٌ لا استثناء — فالطلب غير الموقَّع هو أكثر ما يستقبله endpoint مفتوح اعتيادية.
لماذا يسكن التحقق في خدمة خاصة به
في منصة التجارة داخل واتساب، هذا الكود لا يسكن في تطبيق Laravel. بل في بوابة Node منفصلة: أربعة عشر ملفاً، بلا قاعدة بيانات وبلا قواعد أعمال. مسار Laravel واحد كان يعني بنية تحتية أقل، ولنظام أصغر ربما كان القرار الصحيح. لكن ثلاثة أمور دفعت للاتجاه الآخر.
الـ body الخام أسهل حمايةً حيث لا يلمسه شيء آخر. داخل تطبيق كامل يجب أن يعمل التحقق قبل أن يقرأ أي شيء الطلب، ويبقى صحيحاً ما دام لا أحد يضيف middleware أمامه. أما في خدمة وظيفتها كلها هذا التحقق، فلا شيء أمامه.
التشفير لا شأن له بجوار الفواتير. WhatsApp Flows تضيف حمولات مشفَّرة، أي تعاملاً مع مفاتيح RSA و AES. هذا نوع مختلف من الكود عن تسعير الطلبات، باختبارات من نوع مختلف، ويتغيّر لأسباب مختلفة.
الإقرار يجب أن يكون سريعاً. Meta تعيد إرسال التسليم الذي لم يُقَر، فالمعالج البطيء يحوّل رسالة واحدة إلى عدة رسائل. وخدمة نحيفة تتحقق وتفك التشفير وتوحّد الشكل وتمرّر تستطيع الرد فوراً، مهما كان العمل خلفها بطيئاً.
الحدّ الثاني
حين تمرّر البوابة رسالة إلى الداخل، يواجه التطبيق السؤال الذي أجابت عنه البوابة للتو: كيف يعرف من أرسل هذا؟ «جاء من بوابتنا» افتراضٌ عن تخطيط الشبكة، والتخطيطات تتغيّر.
لذلك هذه القفزة موقَّعة أيضاً، في الاتجاهين، بسرّ تتشاركه الخدمتان. ولا تتصرف أي منهما على طلب لم توقّعه الأخرى. ومن جهة التطبيق، التحقق middleware صغير واحد — وهذا هيكله:
final class VerifyGatewaySignature
{
public function handle(Request $request, Closure $next): Response
{
$expected = hash_hmac('sha256', $request->getContent(), config('gateway.secret'));
// hash_equals هي المقارنة ثابتة الزمن في PHP.
abort_unless(
hash_equals($expected, (string) $request->header('X-Gateway-Signature')),
401,
);
return $next($request);
}
}وإذا وقّعت طابعاً زمنياً مع الـ body، يستطيع المستقبِل أيضاً رفض الطلب القديم أكثر من اللازم — وهذا بالضبط ما يكونه الطلب المُعاد إرساله.
ماذا كلّف، وماذا كسب
أربعة عشر ملفاً ومجموعة اختبارات تشفير خاصة بها. هذا هو الثمن كله.
وما كسبه أن بروتوكول Meta ومنطق العمل صارا يتغيّران باستقلال. حين يتغيّر مظروف webhook، تتحرك البوابة ولا يُفتح أي كود أعمال. وحين تتغيّر قواعد التسعير أو سلوك الفروع، لا تُمَس البوابة.
عندما تتكامل مع منصة لا تتحكم بها، ضعها خلف خدمة تتحكم بها. هذا الحدّ يسدّد ثمنه من أول مرة يغيّر المزوّد شيئاً — ومع منصة بهذا الحجم، ذلك ليس بعيداً أبداً.
