یک خط کد میتواند کل مدل کنترل دسترسی شما را از کار بیندازد. اگر بدنه درخواست (request body) را مستقیماً در یک عملیات بهروزرسانی پایگاه داده قرار دهید، در واقع قلمی را در دست کلاینت گذاشتهاید تا Schema شما را بازنویسی کند. این همان Mass Assignment است. این یک باگ عجیب یا یک مورد نادر نیست؛ بلکه یک شکست در طراحی است که هر زمان یک API با یک Payload به عنوان سیاست بهروزرسانی خود برخورد کند، ظاهر میشود.
await db.users.update(req.params.id, { ...req.body });
تمیز به نظر میرسد و در تایپ کردن صرفهجویی میکند. اما کلیدها در کنترل کلاینت هستند. یک مهاجم میتواند به یک بهروزرسانی معمولیِ پروفایل، مواردی مثل "role": "admin"، "accountId": "someone_else" یا "credit": 99999" را اضافه کند. لایه اعتبارسنجی (validation layer) شما ممکن است بررسی کند که آیا این مقادیر رشته (string) هستند یا عدد، و بگوید که مشکلی ندارند. با این حال، «معتبر بودن» با «مجاز بودن» (authorization) متفاوت است. یک کاربر ممکن است به درستی مالک رکورد مورد نظر باشد، اما این بدان معنا نیست که باید حق ویرایش هر فیلد موجود در آن را داشته باشد.
Mass Assignment در واقعیت چگونه به نظر میرسد
خطر در راحتی نهفته است. فریمورکها و ORMها نگاشت مستقیم کلیدهای JSON به ستونهای پایگاه داده را بسیار ساده میکنند. وقتی این کار را انجام میدهید، در واقع به پایگاه داده میگویید که به کلاینت در مورد اینکه چه چیزی باید تغییر کند اعتماد کند، نه فقط اینکه چگونه باید تغییر کند.
کاربری که پروفایل خود را بهروزرسانی میکند ممکن است دادههای معتبری برای displayName و bio ارسال کند، اما در کنار آنها role یا balance را هم مخفیانه بگنجاند. اگر کنترلر شما صرفاً شیء (object) را ارسال کند، پایگاه داده همه آنها را مینویسد. اعتبارسنجی، مقادیر بدشکل (malformed) را شناسایی میکند، اما به ندرت کلیدهای مخرب را پیدا میکند. قانون تجاری که میگوید «این کاربر اجازه دارد پروفایل خود را بهروزرسانی کند»، به یک اجازه کلی برای تمام ستونهای آن ردیف تبدیل میشود.
راه حل، اعتبارسنجی بیشتر نیست؛ بلکه معماری سختگیرانهتر است.
سه دروازه
یک تغییر (mutation) امن، پیش از آنکه با فضای ذخیرهسازی تماس پیدا کند، از سه مرحله بررسی مجزا عبور میکند.
فیلدهای پذیرفتهشده (Allowlist)
با تصمیمگیری دقیق در مورد اینکه اصلاً به کدام کلیدها توجه خواهید کرد، شروع کنید. اگر فیلدی در لیست مجاز (allowlist) نیست، درخواست را رد کنید یا آن کلید را حذف کنید. این کار رویکرد پیشفرض را تغییر میدهد: ستونهای جدید پایگاه داده تا زمانی که یک توسعهدهنده صراحتاً آنها را در دسترس قرار ندهد، غیرقابل نوشتن خواهند بود. Schemaها در طول زمان رشد میکنند. یک همکار ممکن است فیلدهایی مثل stripeCustomerId ،departmentBudget یا یک پرچم isVerified اضافه کند. با داشتن یک allowlist، این ستونهای جدید بهطور خودکار در برابر نوشتن توسط کلاینت محافظت میشوند. بدون آن، هر ستون جدید به یک سطح دسترسی ناخواسته در API تبدیل میشود.
مقادیر معتبر
پس از اینکه مشخص شد کدام فیلدها مجاز هستند، بررسی کنید که آیا مقادیر منطقی هستند یا خیر. آیا رشتهی منطقه زمانی (timezone) واقعاً یک منطقه زمانی شناختهشده است؟ آیا فرمت ایمیل درست است؟ آیا عدد در یک محدوده منطقی قرار دارد؟ این کار از نوع بهداشت داده (hygiene) است. این کار از ورود زباله به سیستم شما جلوگیری میکند، اما جلوی سوءاستفاده را نمیگیرد. یک رشتهی کاملاً معتبر "admin" در فیلد role همچنان خطرناک است، اگر فرد اشتباهی آن را ارسال کند.
تغییرات مجاز (Authorized Transitions)
این دروازهای است که اکثر تیمها از آن عبور میکنند و محافظت واقعی در همینجا نهفته است. یک سوال دقیق بپرسید: آیا این کنشگر (actor) خاص، اجازه دارد این فیلد خاص را در این رکورد خاص تغییر دهد؟ نه اینکه بپرسید «آیا کاربر ادمین است؟» یا «آیا کاربر محدوده (scope) write:users را دارد؟» بلکه بپرسید: «آیا این کاربر اجازه دارد displayName خودش را تغییر دهد، اما هرگز اجازه تغییر accountId را ندارد؟» مجوزدهی به ازای هر فیلد (Per-field authorization) باعث میشود که یک اجازه گسترده مانند «Editor» یا «User»، به کلیدی اصلی برای تمام ویژگیهای آن ردیف تبدیل نشود.
ساخت تابع Patch
سه دروازه را در قالب یک خط لوله (pipeline) واحد به هم متصل کنید. وقتی یک درخواست patch میرسد، آن را به ترتیب از مراحل عبور دهید.
اول، ورودی را با allowlist خود فیلتر کنید. اگر role فیلد مجاز برای این Endpoint نیست، همانجا متوقف شوید. دلیلی ندارد مقداری را که هرگز نباید دریافت میکردید، اعتبارسنجی یا مجازسازی کنید.
دوم، مقادیر مجاز را اعتبارسنجی کنید. انواع (types)، فرمتها و قوانین تجاری را بررسی کنید. یک فیلد مکان باید رشتهای باشد که به یک منطقه زمانی واقعی ختم شود. یک URL آواتار باید یک URI معتبر با طول مشخصی باشد.
سوم، عملیات را مجازسازی کنید. تأیید کنید که کنشگر مالک رکورد مورد نظر است یا دقیقاً مجوز لازم برای این فیلد را دارد. مالکیت یک پیشفرض خوب برای دادههای شخصی است، اما برخی فیلدها همچنان به دروازههای اضافی نیاز دارند. یک کاربر ممکن است مالک پروفایل خود باشد، اما فقط یک مدیر صورتحساب (billing admin) باید بتواند taxRegion را تغییر دهد.
چهارم، دادهها را نرمالسازی (normalize) کنید. فضاهای خالی (whitespace) را حذف کنید، فاصلههای تکراری را یکپارچه کنید، ایمیلها را به حروف کوچک تبدیل کنید یا کاراکترهای کنترلی را پاک کنید. این کار را بعد از اعتبارسنجی اما قبل از ذخیرهسازی انجام دهید تا در حین بررسیهای مجوزدهی، با رشتههای کثیف (dirty strings) مقایسه نکنید.
اگر ورودی در هر یک از مراحل بررسی (gate) شکست خورد، کل تغییر (mutation) را رد کنید. فیلدهای امن را به صورت جزئی اعمال نکنید و فیلدهای نامعتبر را بیصدا حذف نکنید. یک پاسخ مختلط باعث میشود کلاینتها یاد بگیرند هر کلیدی را که به ذهنشان میرسد امتحان کنند تا ببینند کدام یک پذیرفته میشود. به طور صریح خطا بدهید.
موارد خاصی که واقعاً اهمیت دارند
دفاع در برابر Mass assignment به جزئیاتی وابسته است که اغلب در تستهای واحد (unit tests) نادیده گرفته میشوند.
کلیدهای تکراری JSON. مهاجمان میتوانند پلودهایی مانند {"role": "user", "role": "admin"} ارسال کنند. بسته به پارسر HTTP و فریمورک شما، ممکن است مقدار دوم قبل از اینکه کد اپلیکیشن شما شیء را ببیند، جایگزین مقدار اول شود. این رفتار را در سطح پارسر تست کنید. اگر فریمورک شما آخرین کلید را بیصدا میپذیرد، لیست مجاز (allowlist) شما ممکن است به "user" نگاه کند در حالی که دیتابیس "admin" را دریافت میکند.
اشیاء تودرتو، مقادیر null و آرایهها. فرض نکنید که پلود تخت (flat) است. یک کلاینت ممکن است یک فیلد محدود شده را درون یک شیء تودرتو مانند { "profile": { "role": "admin" } } قرار دهد. اگر طرحواره (schema) شما بازگشتی (recursive) است، لیست مجاز شما نیز باید بازگشتی باشد. به همین ترتیب، تصمیم بگیرید که چگونه با null برخورد میکنید. آیا به معنای «نادیده گرفتن این فیلد» است یا «حذف این فیلد»؟ و اگر انتظار یک آرایه میرود، آیا اعتبارسنج (validator) شما ساختارهای غیرمنتظره را رد میکند، یا یک شیء واحد را به آرایه تبدیل کرده و اجازه عبور میدهد؟
نرمالسازی یونیکد (Unicode normalization). دو رشته میتوانند برای یک انسان کاملاً یکسان به نظر برسند، در حالی که توالیهای بایت متفاوتی هستند. یک کاربر ممکن است یک é ترکیبی یا یک e تجزیهشده به همراه علامت ترکیبکننده ارسال کند. اگر بررسی مجوز (authorization) شما یک بار نرمالسازی میکند اما لایه ذخیرهسازی شما به شکل متفاوتی نرمالسازی میکند، ممکن است با دادههای ناسازگار یا بدتر از آن، با یک دور زدن (bypass) مواجه شوید که در آن تداخل نام کاربری از منطق شما عبور میکند. نرمالسازی را زود و به صورت یکپارچه انجام دهید.
شرایط رقابتی (Race conditions). تصمیمات مربوط به مجوز، تصاویر ثابت نیستند؛ آنها در یک لحظه خاص رخ میدهند. دو درخواست میتوانند یک رکورد را بخوانند، هر دو ببینند که کاربر اجازه نوشتن دارد و هر دو دستور بهروزرسانی صادر کنند. در این فاصله، وضعیت یا مجوزهای کاربر ممکن است تغییر کرده باشد. همیشه بهروزرسانیهای دیتابیس را با شرطی روی شماره نسخه (version number) یا مقدار ماشین حالت (state machine) اعمال کنید. از چیزی شبیه به این استفاده کنید: UPDATE users SET ... WHERE id = ? AND version = 5. اگر از زمانی که رکورد را خواندهاید تغییر کرده باشد، عملیات نوشتن با شکست مواجه میشود. با تلاش مجدد یا رد کردن، این شکست را مدیریت کنید. این کار از خراب شدن دادههای شما توسط بررسیهای مجوز قدیمی جلوگیری میکند.
آنچه اهمیت دارد را مانیتور کنید
شما نمیتوانید چیزی را که نمیبینید، ایمن کنید. ثبت وقایع (audit logging) خود را حول محور «تصمیم»، و نه فقط «اقدام»، بنا کنید.
شناسه کاربر (actor ID) و شناسه هدف (target ID) را ثبت کنید. نام دقیق فیلدهایی که پذیرفته شدهاند و فیلدهایی که رد شدهاند را ثبت کنید. نسخه سیاستی (policy version) که تصمیم را گرفته و نتیجه نهایی را ثبت کنید. اگر کاربری ناگهان در بهروزرسانی پروفایل خود با رد شدن فیلد role مواجه شد، باید بلافاصله مطلع شوید.
هرگز bearer tokenها را ثبت نکنید. هرگز کل بدنه درخواست (request body) را در لاگهای خود نریزید. یک ردپای حسابرسی (audit trail) باید به شما در بررسی سوءاستفادهها کمک کند، نه اینکه به مخزنی از اعتبارنامهها و دادههای شخصی تبدیل شود.
تنها قانونی که نیاز دارید
بدنه یک درخواست، دادهها را پیشنهاد میدهد؛ اما هرگز مرجعیت خود را تعریف نمیکند. کلاینت میتواند هر چیزی را درخواست کند. سرور شما، فیلد به فیلد و ردیف به ردیف، تصمیم میگیرد که چه چیزی اجازه دارد در ذخیرهسازی دائمی قرار بگیرد. وصلههای (patches) خود را با در نظر گرفتن این تفکیک بسازید، و در این صورت mass assignment به مشکلی تبدیل میشود که مدتها پیش از رسیدن به لایه مجوز شما، آن را متوقف کردهاید.
