Mass Assignment في Laravel: كيف يصبح المستخدم مديرًا بحقل مخفي
ثغرة Mass Assignment في Laravel تسمح لمستخدم عادي بتعديل أعمدة لم تقصد إتاحتها، مثل is_admin، بمجرد إضافة حقل إلى الطلب. إليك كيف تحدث وكيف تغلقها.
سطر واحد مثل User::create($request->all()) يبدو بريئًا، لكنه قد يكون الفرق بين تطبيق آمن وتطبيق يستطيع أي زائر فيه ترقية نفسه إلى مدير. في هذا المقال نشرح آلية الثغرة بمثال عملي، وما الذي يحميك افتراضيًا في Eloquent، وأين تقع الأخطاء الحقيقية، وقائمة قصيرة تطبّقها اليوم على مشروعك.
ما هي ثغرة Mass Assignment؟
بحسب OWASP، تحدث الثغرة عندما يربط الإطار معاملات طلب HTTP بخصائص الكائن تلقائيًا، فيستطيع المهاجم إرسال حقول إضافية لم يتوقعها المطوّر فتُكتب في الكائن. في المثال الكلاسيكي لدى OWASP يعرض النموذج ثلاثة حقول فقط، فيضيف المهاجم isAdmin=true إلى الطلب. ولا يحتاج المهاجم إلى الاطلاع على الكود، بل إلى تخمين أسماء الأعمدة فقط، وهي في الغالب متوقعة: is_admin وrole وbalance.
وتوثّق OWASP حادثة حقيقية: في 2012 استغل مستخدم هذه الثغرة في GitHub ليرفع مفتاحًا عامًا (public key) إلى أي منظمة، وبذلك تمكّن من تعديل مستودعاتها.
وتصف وثائق Laravel الخطر نفسه: مستخدم خبيث يرسل المعامل is_admin فيمرّ إلى create فيرقّي نفسه إلى مدير.
الكود المعرّض للخطر
تخيّل نموذج تسجيل فيه name وemail وpassword فقط، وجدول users فيه عمود is_admin:
// ❌ خطر: كل ما يرسله المستخدم يصل إلى النموذج
public function store(Request $request)
{
$user = User::create($request->all());
return response()->json($user, 201);
}
إذا كان النموذج قد سُمح فيه بالتعبئة الجماعية لكل الأعمدة، يكفي المهاجم طلب مثل:
curl -X POST https://example.com/register \
-d "name=Ali" -d "email=ali@example.com" -d "password=secret123" \
-d "is_admin=1"
الحقل is_admin غير موجود في نموذج HTML، لكن لا شيء يمنع إرساله يدويًا. الواجهة الأمامية ليست خط دفاع.
ما الذي يحميك افتراضيًا في Eloquent؟
وثائق Laravel 13.x تقول إن نماذج Eloquent محمية من Mass Assignment افتراضيًا، وإنك تحتاج إلى تحديد Fillable أو Guarded على النموذج قبل استعمال create. الأعمدة غير المدرجة في Fillable تُهمَل بصمت عند التعبئة الجماعية.
في الوثائق الحالية تُعرَّف القائمة عبر سمة (attribute) على الكلاس:
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'email', 'password'])]
class User extends Model
{
// ...
}
والفكرة هنا هي قائمة سماح (allow-list): أنت تسمّي ما يجوز كتابته، وكل ما عداه محمي. وهذا ما توصي به OWASP أيضًا، إذ تحذّر من الاكتفاء بقوائم المنع لأن أي عمود يُضاف لاحقًا يبقى قابلًا للربط. إذا كان مشروعك أقدم فقد تجد الخاصية $fillable بدل السمة، والمبدأ واحد.
متى تتعطل هذه الحماية؟
الحماية الافتراضية جيدة، لكن الثغرة تعود بأحد طريقين:
- توسيع Fillable أكثر من اللازم. إضافة
is_adminأوroleإلى القائمة لأن "الأدمن يحتاج تعديلها" تفتح الباب لكل مستخدم يصل إلى ذلك الـ endpoint. - إلغاء الحماية. تذكر الوثائق السمة
Unguardedالتي تجعل كل الأعمدة قابلة للتعبئة، وتنبّه إلى وجوب أن تصنع المصفوفات المرسلة إلىfillوcreateوupdateيدويًا إذا فعلت ذلك. أي أن$request->all()مع نموذج غير محمي هو الثغرة بعينها.
الحل الصحيح: لا تمرّر الطلب الخام أبدًا
حتى مع Fillable سليم، العادة الأفضل ألا يصل إلى النموذج إلا ما تحقّقت منه. وثائق Laravel توفّر validated() وsafe():
// ✅ البيانات التي اجتازت التحقق فقط
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
return response()->json($user, 201);
}
في هذه الحالة تعرّف في الـ Form Request قواعد الحقول المسموحة فقط (name وemail وpassword)، وvalidated() يعيد البيانات التي اجتازت التحقق، فلن يصل is_admin حتى لو أُرسل. وإن أردت تضييق الحقول أكثر:
$data = $request->safe()->only(['name', 'email']);
وهنا طبقتا حماية: قواعد التحقق تحدد ما يدخل من الطلب، وFillable يحدد ما يُكتب في النموذج. إذا أخطأت في إحداهما أنقذتك الأخرى.
عندما تحتاج فعلًا إلى تعديل حقل حساس
ترقية مستخدم إلى مدير عملية مشروعة، لكن لا تجعلها جزءًا من التعبئة الجماعية العامة. اجعلها مسارًا مستقلًا بتحقق صلاحيات صريح، وتعيين الحقل مباشرة:
public function promote(User $user)
{
$this->authorize('promote', $user);
$user->is_admin = true;
$user->save();
}
تذكّر قاعدة OWASP الأخيرة: قوائم الحقول المسموحة تحدد ما يمكن كتابته، لكنها لا تغني عن التحقق من صلاحية المستخدم على هذا الكائن وهذه العملية.
الملكية: الوجه الأقل شهرة للثغرة
ليست الصلاحيات وحدها ما يتأثر. تخيّل جدول posts فيه user_id وأدرجته في Fillable ثم مرّرت الطلب إلى create. يستطيع المستخدم حينها إرسال user_id لشخص آخر فيُنشأ المحتوى باسم غيره، وقد يعدّل سجلًّا ليس له إذا مرّرت update الخام. الحل أن تأخذ الملكية من المستخدم المصادَق عليه لا من الطلب:
// ✅ user_id يُملأ من العلاقة، لا من مدخلات المستخدم
$post = $request->user()->posts()->create($request->validated());
وأخرج user_id من قواعد التحقق ومن Fillable إن أمكن. والقاعدة نفسها تنطبق على update وfill: كل ما تمرّره من الطلب مباشرة هو مدخل غير موثوق.
اكتشاف المشكلة أثناء التطوير
بشكل افتراضي تُهمَل الحقول غير القابلة للتعبئة بصمت، وهذا سلوك مقبول في الإنتاج لكنه محيّر محليًا: تعدّل حقلًا ولا يتغير شيء. توفر Laravel تفعيل استثناء بدل الإهمال الصامت:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventSilentlyDiscardingAttributes($this->app->isLocal());
}
ضع هذا في AppServiceProvider. فإذا أرسلت اختباراتك حقلًا غير مدرج في Fillable ظهر الخطأ فورًا بدل أن يختفي الحقل بهدوء.
ملاحظة عن أعمدة JSON
بحسب الوثائق، عند تعبئة أعمدة JSON يجب ذكر مفتاح كل عمود قابل للتعبئة في Fillable، مثل options->enabled. ولأسباب أمنية لا تدعم Laravel تحديث خصائص JSON المتداخلة عند استعمال Guarded. لهذا فضّل Fillable دائمًا.
قائمة تدقيق سريعة
- ابحث في مشروعك عن
->all()داخلcreate(وupdate(وfill(، واستبدلها بـvalidated(). - ابحث عن
UnguardedأوModel::unguard()وتأكد أن استخدامها محصور في الـ seeders والاختبارات. - راجع قوائم
Fillableوأخرج منها أي عمود يتعلق بالصلاحيات أو الرصيد أو الملكية (is_admin,role,user_id,balance). - فعّل
preventSilentlyDiscardingAttributesمحليًا. - أضف اختبارًا يرسل
is_admin=1إلى نقطة التسجيل ويتأكد أن القيمة لم تُحفظ.
الاختبار الأخير مثال بسيط:
// يفترض أن الاختبار يستعمل RefreshDatabase
it('does not allow mass assigning is_admin', function () {
$this->postJson('/register', [
'name' => 'Ali',
'email' => 'ali@example.com',
'password' => 'secret123',
'is_admin' => 1,
])->assertCreated();
expect(User::first()->is_admin)->toBeFalsy();
});
الخلاصة
Eloquent يحميك افتراضيًا، لكن الثغرة تتسلل عندما توسّع Fillable بلا حاجة أو تلغي الحماية أو تمرّر $request->all(). القاعدة العملية: مرّر إلى النموذج validated() فقط، واجعل Fillable قائمة سماح ضيقة، وعالج الحقول الحساسة بمسارات منفصلة مع تحقق من الصلاحيات، وأضف اختبارًا يثبت أن الحقل المخفي لا يمر.