هذا المقال لمستوى متوسط. يفترض إنك بتكتب regex في مشروع ويب (Node أو Python أو أي backend) وإن جزء من الإدخال جاي من المستخدم.
regex واحد غلط يقدر يطلّع المعالج لـ 100% ويوقّع الخدمة كلها بإدخال طوله 30 حرف. مش هجوم معقّد ولا اختراق، مجرد نص بسيط بيخلّي محرك الـ regex يدخل في لفّة حسابية أُسّية. في المقال ده هتعرف إزاي تكتشف الـ regex الخطير ده، وتصلّحه بسطر، وتقيس الفرق بنفسك.
ReDoS: لما تعبير regex واحد يوقّع السيرفر
المشكلة باختصار
ReDoS اختصار لـ Regular expression Denial of Service. الفكرة: في أنواع من الـ regex زمن تنفيذها بيكبر أُسّيًا مع طول الإدخال. يعني كل حرف زيادة بيقرّب يضاعف الزمن. لو الإدخال جاي من المستخدم، أي حد يقدر يبعت نص قصير يخلّي خيط المعالجة يفضل شغّال ثوانٍ أو دقايق، ويستهلك الـ CPU بالكامل. ده مش سيناريو نظري: في 2 يوليو 2019 وقعت Cloudflare عالميًا بسبب regex فيه backtracking كارثي وصل بالـ CPU لـ 100% على كل السيرفرات. وقبلها بسنين وقعت Stack Overflow 34 دقيقة بسبب regex بيشيل المسافات.
ليه بيحصل ده أصلاً؟ الفكرة بمثال بسيط
تعالى نبسّطها الأول. تخيّل إنك بتدوّر على طريقة تقسم بيها كلمة "aaaa" على مجموعتين، وكل مجموعة لازم تكون حرف واحد أو أكتر. ممكن تقسمها: (a)(aaa)، أو (aa)(aa)، أو (aaa)(a)، أو (a)(a)(aa)... وهكذا. عدد الطرق بيكبر بسرعة جنونية كل ما تزود حرف.
ده بالظبط اللي بيحصل في تعبير زي ^(a+)+$. عندك a+ جوّه (...)+ تاني. الاتنين بيقدروا "يبلعوا" نفس الحروف بطرق كتير مختلفة. طول ما النص بيطابق، المحرك بيمشي عادي. لكن أول ما يلاقي حرف في الآخر بيكسر المطابقة (زي !)، بيرجع لورا ويجرّب كل التقسيمات الممكنة قبل ما يستسلم. ده اسمه backtracking، ولما التقسيمات تكون أُسّية يبقى اسمه catastrophic backtracking.
علميًا: محرك الـ regex في JavaScript (V8) وPython وJava وPCRE بيستخدم خوارزمية مبنية على backtracking. لما يكون عندك كمّيات متداخلة أو متداخلة الحدود (nested / overlapping quantifiers)، عدد المسارات اللي المحرك بيجرّبها بيبقى رتبته O(2^n) بالنسبة لطول الإدخال n. الإدخال اللي بيطابق جزئيًا بعدين يفشل في آخر حرف هو أسوأ حالة، لأنه بيجبر المحرك يستكشف كل المسارات.
اقيسها بنفسك — كود Node شغّال
الكود ده بيوضّح الانفجار. شغّله وزوّد الرقم حرف حرف:
// regex فيه nested quantifier — خطير
const evil = /^(a+)+$/;
for (const n of [24, 26, 28, 30, 32]) {
const input = 'a'.repeat(n) + '!'; // نص بيطابق جزئيًا وبعدين يفشل
const t = process.hrtime.bigint();
evil.test(input);
const ms = Number(process.hrtime.bigint() - t) / 1e6;
console.log(`n=${n} time=${ms.toFixed(1)}ms`);
}