گاهی برای زمین زدن یه سرور لازم نیست هزاران Request همزمان فرستاده بشه یا یه باگ عجیب تو دیتابیس وجود داشته باشه؛ بعضی وقت‌ها فقط یه رشته متن خاص کافیه تا یه Regular Expression به ظاهر ساده، CPU رو درگیر کنه و Event Loop رو برای چند ثانیه از کار بندازه. این مدل مشکل با اسم ReDoS یا Regular Expression Denial of Service شناخته میشه و یکی از اون باگ‌هاییه که معمولاً تا وقتی ورودی خاصی به برنامه نرسه، هیچ نشونه‌ای از خودش نشون نمیده.

مشکل اصلی از جایی شروع میشه که Regex مجبور میشه برای پیدا کردن Match، مسیرهای خیلی زیادی رو امتحان کنه. این اتفاق معمولاً به خاطر چیزی به اسم Catastrophic Backtracking رخ میده؛ یعنی موتور Regex چندین ترکیب مختلف رو بررسی میکنه، شکست میخوره، برمیگرده عقب و دوباره مسیر دیگه‌ای رو امتحان میکنه. روی یه ورودی کوتاه شاید این عملیات در کسری از میلی‌ثانیه تموم بشه، ولی با بزرگ‌تر شدن ورودی تعداد حالت‌ها میتونه به شکل وحشتناکی زیاد بشه.

یه Regex ساده چطور دردسر درست میکنه؟

فرض کنید برای اعتبارسنجی یه رشته چنین الگویی نوشته شده:

const regex = /^(a+)+$/;

console.time('regex');

regex.test(
  'aaaaaaaaaaaaaaaaaaaaaaaaaaaa!'
);

console.timeEnd('regex');

در نگاه اول Regex خیلی پیچیده‌ای نیست، ولی مشکل اینجاست که a+ داخل یه گروه قرار گرفته و خود اون گروه هم دوباره با + تکرار میشه. وقتی رشته کاملاً از a تشکیل شده باشه، موتور خیلی راحت Match رو پیدا میکنه؛ اما وقتی در انتهای رشته یه کاراکتر ناسازگار مثل ! قرار بگیره، Regex شروع میکنه حالت‌های مختلف تقسیم اون aها رو امتحان کردن تا شاید یه راه برای Match پیدا کنه.

هرچقدر طول رشته بیشتر بشه، تعداد این بررسی‌ها میتونه خیلی سریع رشد کنه و CPU رو برای مدت قابل توجهی درگیر نگه داره.

چرا ReDoS تو Node.js خطرناک‌تره؟

Node.js برای اجرای کد JavaScript عمدتاً به Event Loop متکیه. یعنی وقتی یه Regex سنگین روی Thread اصلی اجرا میشه، برنامه نمیتونه خیلی راحت بگه «این یکی داره طول میکشه، بذار فعلاً برم Request بعدی رو جواب بدم».

تا وقتی اجرای Regex تموم نشده، باقی کدهای JavaScript هم باید منتظر بمونن.

حالا تصور کنید این Regex داخل Endpoint ثبت‌نام، جستجو، اعتبارسنجی ایمیل یا پردازش Query String قرار گرفته باشه. یه Request با ورودی خاص میاد، CPU درگیر میشه و در همون مدت Requestهای کاملاً سالم کاربران دیگه هم با تأخیر جواب میگیرن.

به همین دلیل ReDoS فقط یه مشکل Performance معمولی نیست؛ اگر Regex روی ورودی کنترل‌شده توسط کاربر اجرا بشه، میتونه تبدیل به یه مسئله امنیتی واقعی بشه.

چه Regexهایی بیشتر مشکوکن؟

هر Regex پیچیده‌ای الزاماً خطرناک نیست، ولی بعضی الگوها ارزش بررسی بیشتری دارن. مخصوصاً وقتی Quantifierهایی مثل +، * یا {x,y} به شکل تو در تو استفاده میشن یا چند بخش مختلف Regex میتونن یه قسمت مشابه از رشته رو Match کنن.

مثلاً ساختارهایی شبیه (a+)+ یا الگوهایی که چند مسیر تقریباً مشابه برای Match کردن دارن، ممکنه باعث Backtracking شدید بشن.

مشکل اینجاست که Regex روی ورودی‌های معمولی کاملاً سریع کار میکنه و حتی تست‌های پروژه رو هم پاس میکنه. بعد یه ورودی غیرعادی و طولانی میرسه و تازه مشخص میشه که زمان اجرای Regex اصلاً خطی نیست.

اولین دفاع: اندازه ورودی رو محدود کنید

اگر قراره Regex روی داده‌ای اجرا بشه که کاربر ارسال میکنه، یکی از ساده‌ترین کارها اینه که اجازه ندید ورودی بدون محدودیت بزرگ بشه. مثلاً اگه Username حداکثر باید ۳۰ کاراکتر باشه، منطقی نیست یه رشته ۲۰۰ هزار کاراکتری رو اول تحویل Regex بدید و بعد تصمیم بگیرید معتبر هست یا نه.

قبل از اجرای Regex میتونید یه محدودیت ساده بذارید:

function isValidUsername(value) {
  if (typeof value !== 'string' || value.length > 30) {
    return false;
  }

  return /^[a-zA-Z0-9_]+$/.test(value);
}

این کار علاوه بر جلوگیری از پردازش بیهوده، سطح حمله رو هم به شکل قابل توجهی کم میکنه. البته محدود کردن ورودی جای طراحی Regex مناسب رو نمیگیره، ولی یه لایه دفاعی خیلی ساده و مؤثره.

Regex رو ساده‌تر بنویسید، نه باهوش‌تر!

گاهی Regexها فقط به این دلیل پیچیده میشن که برنامه‌نویس میخواد تمام قوانین اعتبارسنجی دنیا رو داخل یه Expression جا بده. نتیجه هم یه خط ۲۰۰ کاراکتری میشه که هیچ‌کس دقیقاً نمیدونه چه کاری انجام میده و تغییر دادنش هم ترسناکه.

خیلی وقت‌ها بهتره اعتبارسنجی رو به چند مرحله ساده تقسیم کنید. مثلاً اول طول ورودی رو بررسی کنید، بعد فرمت کلی رو با یه Regex ساده چک کنید و قوانین خاص‌تر رو با JavaScript انجام بدید.

Regex کوتاه‌تر شاید به اندازه یه Expression عجیب و غریب باحال به نظر نرسه، ولی شش ماه بعد وقتی خواستید باگش رو پیدا کنید، از خودتون تشکر میکنید!

به Regexهای پکیج‌های خارجی هم اعتماد کور نکنید

ReDoS فقط از Regexهایی که خودتون نوشتید نمیاد. ممکنه یه Dependency برای Parse کردن URL، اعتبارسنجی داده یا پردازش متن از Regex آسیب‌پذیر استفاده کرده باشه. به همین دلیل آپدیت نگه داشتن Dependencyها و بررسی هشدارهای امنیتی پکیج‌ها هم اهمیت داره.

اگه یه پکیج قدیمی روی ورودی مستقیم کاربر پردازش Regex انجام میده، ارزش داره Release Noteها و مشکلات امنیتی نسخه‌های جدیدش رو بررسی کنید.

از کجا بفهمیم Regex عامل کندی سروره؟

یکی از نشونه‌های مهم اینه که CPU ناگهان بالا میره، ولی دیتابیس و سرویس‌های خارجی سالم هستن و مصرف RAM هم الزاماً اتفاق عجیبی نشون نمیده. ممکنه فقط یه Endpoint خاص روی بعضی ورودی‌ها چند ثانیه طول بکشه، در حالی که همون Endpoint برای اکثر کاربران فوق‌العاده سریعه.

در چنین شرایطی باید دنبال پردازش‌های Synchronous بگردید؛ Regexهای پیچیده، حلقه‌های سنگین و Parse کردن داده‌های بزرگ از اولین مظنون‌ها هستن.

اگر زمان اجرای Regex با بزرگ‌تر شدن ورودی به شکل غیرعادی بالا میره، باید ساختارش رو دوباره بررسی کنید.

جمع بندی

Regular Expression یکی از کاربردی‌ترین ابزارهای JavaScript برای اعتبارسنجی و پردازش متنه، ولی یه Regex بد طراحی‌شده میتونه خیلی بیشتر از چیزی که ظاهر ساده‌ش نشون میده CPU مصرف کنه. مشکل ReDoS زمانی ایجاد میشه که موتور Regex به خاطر Backtracking شدید مجبور بشه تعداد زیادی مسیر مختلف رو امتحان کنه و این اتفاق روی Node.js میتونه Event Loop رو هم درگیر کنه.

برای جلوگیری از این مشکل، ورودی کاربران رو محدود کنید، Regexها رو تا جای ممکن ساده نگه دارید، از Quantifierهای تو در تو با احتیاط استفاده کنید و Regexهایی که روی داده خارجی اجرا میشن رو با ورودی‌های طولانی و غیرعادی هم تست کنید.

گاهی یه Regex پنج‌کاراکتری خطرناک‌تر از صد خط کده؛ چون تا روزی که ورودی مناسب بهش نرسه، کاملاً بی‌گناه به نظر میرسه!