به ASP.NET Core 7، یک میانافزار جدید به نام Rate limiter اضافه شدهاست که امکان محدود سازی دسترسی به منابع برنامهی ما را میسر میکند. این میانافزار، طراحی جامع و مفصلی را دارد. به همین جهت نیاز است در ابتدا با مفاهیم مرتبط با آن آشنا شد و سپس به سراغ پیاده سازی و استفادهی از آن رفت.
چرا باید میزان دسترسی به منابع یک برنامهی وب را محدود کرد؟
فرض کنید در حال ساخت یک web API هستید که کارش ذخیره سازی لیست وظایف اشخاص است و برای مثال از یک GET /api/todos برای دریافت لیست ظایف، یک POST /api/todos برای ثبت و یک PUT /api/todos/{id} برای تغییر موارد ثبت شده، تشکیل میشود.
سؤال: چه مشکلی ممکن است به همراه این سه endpoint بروز کند؟
پاسخ: به حداقل چهار مورد زیر میتوان اشاره کرد:
- یک مهاجم سعی میکند با برنامهای که تدارک دیده، هزاران وظیفهی جدید را در چند ثانیه به سمت برنامه ارسال کند تا سبب خاتمهی سرویس آن شود.
- برنامهی ما در حین سرویس دهی، به یک سرویس ثالث نیز وابستهاست و آن سرویس ثالث، اجازهی استفادهی بیش از اندازهی از منابع خود را نمیدهد. با رسیدن تعداد زیادی درخواست به برنامهی ما تنها از طرف یک کاربر، به سقف مجاز استفادهی از آن سرویس ثالث رسیدهایم و اکنون برنامه، برای تمام کاربران آن قابل استفاده نیست.
- شخصی در حال دریافت اطلاعات تک تک کاربران است. از شماره یک شروع کرده و به همین نحو جلو میرود. برای دریافت اطلاعات کاربران، نیاز است شخص به سیستم وارد شده و اعتبارسنجی شود؛ یعنی به ازای هر درخواست، یک کوئری نیز به سمت بانک اطلاعاتی جهت بررسی وضعیت فعلی و آنی کاربر ارسال میشود. به همین جهت عدم کنترل میزان دسترسی به لیست اطلاعات کاربران، بار سنگینی را به بانک اطلاعاتی و CPU سیستم وارد میکند.
- هم اکنون چندین موتور جستجو و باتهایی نظر آنها در حال پیمایش سایت و برنامهی شما هستند که هر کدام از آنها میتوانند در حد یک مهاجم رفتار کنند.
به صورت خلاصه، همیشه استفادهی از برنامه، به آن نحوی که ما پیشبینی کردهایم، به پیش نمیرود و در آن لحظه، برنامه، در حال استفاده از CPU، حافظه و بانک اطلاعاتی به اشتراک گذاشته شدهی با تمام کاربران برنامهاست. در این حالت فقط یک کاربر مهاجم میتواند سبب از کار افتادن و یا به شدت کند شدن این برنامه شود و دسترسی سایر کاربران همزمان را مختل کند.
محدود کردن نرخ دسترسی به برنامه چیست؟
Rate limiting و یا نام دیگر آن request throttling، روشی است که توسط آن بتوان از الگوهای پیش بینی نشدهی استفادهی از برنامه جلوگیری کرد. عموما برنامههای وب، محدود کردن نرخ دسترسی را بر اساس تعداد بار درخواست انجام شدهی در یک بازهی زمانی مشخص، انجام میدهند و یا اگر کار برنامهی شما ارائهی فیلمهای ویدیویی است، شاید بخواهید میزان حجم استفاده شدهی توسط یک کاربر را کنترل کنید. در کل هدف نهایی از آن، کاهش و به حداقل رساندن روشهای آسیب زنندهی به برنامه و سیستم است؛ صرفنظر از اینکه این نحوهی استفادهی خاص، سهوی و یا عمدی باشد.
محدود کردن نرخ دسترسی را باید به چه منابعی اعمال کرد؟
پاسخ دقیق به این سؤال: «همه چیز» است! بله! همه چیز را کنترل کنید! در اینجا منظور از همه چیز، همان endpointهایی هستند که استفادهی نابجای از آنها میتوانند سبب کند شدن برنامه یا از دسترس خارج شدن آن شوند. برای مثال هر endpointای که از CPU، حافظه، دسترسی به دیسک سخت، بانک اطلاعاتی، APIهای ثالث و خارجی و امثال آن استفاده میکند، باید کنترل و محدود شود تا استفادهی ناصحیح یک کاربر از آنها، استفادهی از برنامه را برای سایر کاربران غیرممکن نکند. البته باید دقت داشت که هدف از اینکار، عصبی کردن کاربران عادی و معمولی برنامه نیست. هدف اصلی در اینجا، تشویق به استفادهی منصفانه از منابع سیستم است.
الگوریتمهای محدود کردن نرخ دسترسی
پیاده سازی ابتدایی محدود کردن نرخ دسترسی به منابع یک برنامه کار مشکلی است و در صورت استفاده از الگوریتمهای متداولی مانند تعریف یک جدول که شامل user-id، action-id و timestamp، به همراه یکبار ثبت اطلاعات به ازای هر درخواست و همچنین خواندن اطلاعات موجود است که جدول آن نیز به سرعت افزایش حجم میدهد. به همین جهت تعدادی الگوریتم بهینه برای اینکار طراحی شدهاند:
الگوریتمهای بازهی زمانی مشخص
در این روش، یک شمارشگر در یک بازهی زمانی مشخص فعال میشود و بر این مبنا است که محدودیتها اعمال خواهند شد. یک مثال آن، مجاز دانستن فقط «100 درخواست در یک دقیقه» است که نام دیگر آن «Quantized buckets / Fixed window limit» نیز هست.
برای مثال «نام هر اکشن + یک بازهی زمانی»، یک کلید دیکشنری نگهدارندهی اطلاعات محدود کردن نرخ دسترسی خواهد بود که به آن کلید، «bucket name» هم میگویند؛ مانند مقدار someaction_106062120. سپس به ازای هر درخواست رسیده، شمارشگر مرتبط با این کلید، یک واحد افزایش پیدا میکند و محدود کردن دسترسیها بر اساس مقدار این کلید صورت میگیرد. در ادامه با شروع هر بازهی زمانی جدید که در اینجا window نام دارد، یک کلید یا همان «bucket name» جدید تولید شده و مقدار متناظر با این کلید، به صفر تنظیم میشود.
اگر بجای دیکشنریهای #C از بانک اطلاعاتی Redis برای نگهداری این key/valueها استفاده شود، میتوان برای هر کدام از مقادیر آن، طول عمری را نیز مشخص کرد تا خود Redis، کار حذف خودکار اطلاعات غیرضروری را انجام دهد.
یک مشکل الگوریتمهای بازهی زمانی مشخص، غیر دقیق بودن آنها است. برای مثال فرض کنید که به ازای هر 10 ثانیه میخواهید تنها اجازهی پردازش 4 درخواست رسیده را بدهید. مشکل اینجا است که در این حالت یک کاربر میتواند 5 درخواست متوالی را بدون مشکل ارسال کند؛ 3 درخواست را در انتهای بازهی اول و دو درخواست را در ابتدای بازهی دوم:
به یک بازهی زمانی مشخص، fixed window و به انتها و ابتدای دو بازهی زمانی مشخص متوالی، sliding window میگویند. همانطور که در تصویر فوق هم مشاهده میکنید، در این اگوریتم، امکان محدود سازی دقیقی تنها در یک fixed window میسر است و نه در یک sliding window.
سؤال: آیا این مساله عدم دقت الگوریتمهای بازهی زمانی مشخص مهم است؟
پاسخ: بستگی دارد! اگر هدف شما، جلوگیری از استفادهی سهوی یا عمدی بیش از حد از منابع سیستم است، این مساله مشکل مهمی را ایجاد نمیکند. اما اگر دقت بالایی را انتظار دارید، بله، مهم است! در این حالت از الگوریتمهای «sliding window limit » بیشتر استفاده میشود که در پشت صحنه از همان روش استفادهی از چندین fixed window کوچک، کمک میگیرند.
الگوریتمهای سطل توکنها (Token buckets)
در دنیای مخابرات، از الگوریتمهای token buckets جهت کنترل میزان مصرف پهنای باند، زیاد استفاده میشود. از واژهی سطل در اینجا استفاده شده، چون عموما به همراه آب بکارگرفته میشود:
فرض کنید سطل آبی را دارید که در کف آن نشتی دارد. اگر نرخ پر کردن این سطل، با آب، از نرخ نشتی کف آن بیشتر باشد، آب از سطل، سرریز خواهد شد. به این معنا که با سرریز توکنها یا آب در این مثال، هیچ درخواست جدید دیگری پردازش نمیشود؛ تا زمانیکه مجددا سطل، به اندازهای خالی شود که بتواند توکن یا آب بیشتری را بپذیرد.
یکی از مزیتهای این روش، نداشتن مشکل عدم دقت به همراه بازههای زمانی مشخص است. در اینجا اگر تعداد درخواست زیادی به یکباره به سمت برنامه ارسال شوند، سطل پردازشی آنها سرریز شده و دیگر پردازش نمیشوند.
مزیت دیگر آنها، امکان بروز انفجاری یک ترافیک (bursts in traffic) نیز هست. برای مثال اگر قرار است سطلی با 60 توکن در دقیقه پر شود و این سطل نیز هر ثانیه یکبار تخلیه میشود، کلاینتها هنوز میتوانند 60 درخواست را در طی یک ثانیه ارسال کنند (ترافیک انفجاری) و پس از آن نرخ پردازشی، یک درخواست به ازای هر ثانیه خواهد شد.
آیا باید امکان بروز انفجار در ترافیک را داد؟
عموما در اکثر برنامهها وجود یک محدود کنندهی نرخ دسترسی کافی است. برای مثال یک محدود کنندهی نرخ دسترسی سراسری 600 درخواست در هر دقیقه، برای هر endpoint ای شاید مناسب باشد. اما گاهی از اوقات نیاز است تا امکان بروز انفجار در ترافیک (bursts) را نیز درنظر گرفت. برای مثال زمانیکه یک برنامهی موبایل شروع به کار میکند، در ابتدای راه اندازی آن تعداد زیادی درخواست، به سمت سرور ارسال میشوند و پس از آن، این سرعت کاهش پیدا میکند. در این حالت بهتر است چندین محدودیت را تعریف کرد: برای مثال امکان ارسال 10 درخواست در هر ثانیه و حداکثر 3600 درخواست در هر ساعت.
روش تشخیص کلاینتها چگونه باشد؟
تا اینجا در مورد bucket name یا کلید دیکشنری اطلاعات محدود کردن دسترسی به منابع، از روش «نام هر اکشن + یک بازهی زمانی» استفاده کردیم. به این کار «پارتیشن بندی درخواستها» هم گفته میشود. روشهای دیگری نیز برای انجام اینکار وجود دارند:
پارتیشن بندی به ازای هر
- endpoint
- آدرس IP. البته باید دقت داشت که کاربرانی که در پشت یک پروکسی قرار دارند، از یک IP آدرس اشتراکی استفاده میکنند.
- شماره کاربری. البته باید در اینجا بحث کاربران اعتبارسنجی نشده و anonymous را نیز مدنظر قرار داد.
- شمار سشن کاربر. در این حالت باید بحث ایجاد سشنهای جدید به ازای دستگاههای مختلف مورد استفادهی توسط کاربر را هم مدنظر قرار داد.
- نوع مروگر.
- هدر ویژه رسیده مانند X-Api-Token
بسته به نوع برنامه عموما از ترکیبی از موارد فوق برای پارتیشن بندی درخواستهای رسیده استفاده میشود.
درنظر گرفتن حالتهای استثنائی
هرچند همانطور که عنوان شد تمام قسمتهای برنامه باید از لحاظ میزان دسترسی محدود شوند، اما استثناءهای زیر را نیز باید درنظر گرفت:
- عموما تیم مدیریتی یا فروش برنامه، بیش از سایر کاربران، با برنامه کار میکنند.
- بیش از اندازه محدود کردن Web crawlers میتواند سبب کاهش امتیاز SEO سایت شما شود.
- گروههای خاصی از کاربران برنامه نیز میتوانند دسترسیهای بیشتری را خریداری کنند.
نحوهی خاتمهی اتصال و درخواست
اگر کاربری به حد نهایی استفادهی از منابع خود رسید، چه باید کرد؟ آیا باید صرفا درخواست او را برگشت زد یا اطلاعات بهتری را به او نمایش داد؟
برای مثال GitHub یک چنین خروجی را به همراه هدرهای ویژهای جهت مشخص سازی وضعیت محدود سازی دسترسی به منابع و علت آن، ارائه میدهد:
> HTTP/2 403
> Date: Tue, 20 Aug 2013 14:50:41 GMT
> x-ratelimit-limit: 60
> x-ratelimit-remaining: 0
> x-ratelimit-used: 60
> x-ratelimit-reset: 1377013266
> {
> "message": "API rate limit exceeded for xxx.xxx.xxx.xxx. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)",
> "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting"
> }
بنابراین بسته به نوع خروجی برنامه که اگر خروجی آن یک API از نوع JSON است و یا یک صفحهی HTML، میتوان از ترکیبی از هدرها و اطلاعات متنی و HTML استفاده کرد.
حتی یکسری از APIها از status codeهای ویژهای مانند 403 (دسترسی ممنوع)، 503 (سرویس در دسترس نیست) و یا 429 (تعداد درخواستهای زیاد) برای پاسخ دهی استفاده میکنند.
محل ذخیره سازی اطلاعات محدود سازی دسترسی به منابع کجا باشد؟
اگر محدودسازی دسترسی به منابع، جزئی از مدل تجاری برنامهی شما است، نیاز است حتما از یک بانک اطلاعاتی توزیع شده مانند Redis استفاده کرد تا بتواند اطلاعات تمام نمونههای در حال اجرای برنامه را پوشش دهد. اما اگر هدف از این محدود سازی تنها میسر ساختن دسترسی منصفانهی به منابع آن است، ذخیره سازی آنها در حافظهی همان نمونهی در حال اجرای برنامه هم کافی است.