طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 241 · تحليل وتصميم برمجيات الويب
الدرس 18 · الوحدة 8: تنفيذ تصميم صلاحيات المستخدمين في تطبيقات الويب

طبقات حماية إضافية واختبار الصلاحيات

ما الطبقات الإضافية التي تحمي نظام الصلاحيات، وكيف تختبر أنه يعمل كما صُمم؟ ⏱ نحو 50 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تطبق مبدأ الدفاع بالعمق بطبقات حماية متتالية.
  • تكتشف ثغرة IDOR وتصلحها بشرط الملكية.
  • تمنع الإسناد الجماعي وتتحقق من المدخلات بقوائم السماح.
  • تبني مصفوفة اختبار للصلاحيات بحالات موجبة وسالبة.

١الدفاع بالعمق

وسيطا المصادقة والتفويض ضروريان لكنهما لا يكفيان وحدهما. مبدأ الدفاع بالعمق Defense in Depth: طبقات متتالية، إن أخطأت إحداها أمسكت الأخرى. من الخطة: «تطبيق طبقات إضافية من الحماية مثل التحقق من صحة الطلبات والتحقق من الهوية».

طلب

HTTPS

المصادقة

التفويض

التحقق من المدخلات

الملكية

السجل

المعالج

طبقات الحماية التي يمر بها الطلب قبل أن يصل إلى المعالج.
الطبقةتمنع
HTTPSسرقة الرموز وكلمات المرور أثناء انتقالها
المصادقة + قفل الحساب بعد محاولات فاشلةانتحال الهوية وتخمين كلمات المرور
التفويض بالصلاحيةتنفيذ أفعال لا يملكها الدور
التحقق من المدخلاتقيم غير متوقعة تكسر القواعد (حالة «paid» يرسلها متدرب)
الملكيةالوصول لبيانات مستخدم آخر بتغيير رقم
السجلالإنكار، ويكشف المحاولات المشبوهة

٢الوصول المباشر غير الآمن IDOR

أشهر ثغرة في التحكم بالوصول: الخادم يتحقق أنك مسجّل الدخول، ثم يعطيك أي سجل تطلب رقمه. سارة تملك التسجيل 1، فماذا لو طلبت 2؟

const express = require('express');const app = express();app.use(express.json()); // من مصفوفة الصلاحيات (الدرس 16) إلى الكود مباشرةconst ROLE_PERMS = {  trainee:   ['course:read', 'enrollment:create', 'enrollment:read:own'],  trainer:   ['course:read', 'attendance:record'],  registrar: ['course:read', 'enrollment:read', 'enrollment:approve'],  manager:   ['course:read', 'enrollment:read', 'report:finance', 'user:manage'],};const users = [  { id: 1, name: 'سارة', role: 'trainee' },  { id: 2, name: 'نورة', role: 'registrar' },  { id: 3, name: 'أ. فهد', role: 'manager' },  { id: 4, name: 'خالد', role: 'trainer' },];// رموز دخول مبسطة للتجربة؛ في النظام الحقيقي JWT كما في مقرر أطر الويبconst tokens = { 'tok-sara': 1, 'tok-noura': 2, 'tok-fahad': 3, 'tok-khaled': 4 }; // 1) المصادقة: من أنت؟function authenticate(req, res, next) {  const t = (req.headers.authorization || '').replace('Bearer ', '');  const user = users.find(u => u.id === tokens[t]);  if (!user) return res.status(401).json({ error: 'سجّل الدخول أولًا' });  req.user = user;  next();} // 2) التفويض: هل يحق لك؟const can = (user, perm) => (ROLE_PERMS[user.role] || []).includes(perm);function authorize(perm) {  return (req, res, next) =>    can(req.user, perm) ? next() : res.status(403).json({ error: 'لا تملك الصلاحية ' + perm });} const enrollments = [  { id: 1, traineeId: 1, course: 'تصميم الويب', status: 'pending' },  { id: 2, traineeId: 5, course: 'قواعد البيانات', status: 'pending' },]; app.get('/api/enrollments/:id', authenticate, (req, res) => {  const e = enrollments.find(x => x.id === Number(req.params.id));  if (!e) return res.status(404).json({ error: 'غير موجود' });  res.json(e);                       // ✗ لا يسأل: هل هذا الطلب لك؟}); app.listen(3000);

الطلب الثاني أعاد تسجيل متدرب آخر لسارة! يكفي أن تجرب الأرقام بالتتابع لتجمع بيانات الجميع.

const express = require('express');const app = express();app.use(express.json()); // من مصفوفة الصلاحيات (الدرس 16) إلى الكود مباشرةconst ROLE_PERMS = {  trainee:   ['course:read', 'enrollment:create', 'enrollment:read:own'],  trainer:   ['course:read', 'attendance:record'],  registrar: ['course:read', 'enrollment:read', 'enrollment:approve'],  manager:   ['course:read', 'enrollment:read', 'report:finance', 'user:manage'],};const users = [  { id: 1, name: 'سارة', role: 'trainee' },  { id: 2, name: 'نورة', role: 'registrar' },  { id: 3, name: 'أ. فهد', role: 'manager' },  { id: 4, name: 'خالد', role: 'trainer' },];// رموز دخول مبسطة للتجربة؛ في النظام الحقيقي JWT كما في مقرر أطر الويبconst tokens = { 'tok-sara': 1, 'tok-noura': 2, 'tok-fahad': 3, 'tok-khaled': 4 }; // 1) المصادقة: من أنت؟function authenticate(req, res, next) {  const t = (req.headers.authorization || '').replace('Bearer ', '');  const user = users.find(u => u.id === tokens[t]);  if (!user) return res.status(401).json({ error: 'سجّل الدخول أولًا' });  req.user = user;  next();} // 2) التفويض: هل يحق لك؟const can = (user, perm) => (ROLE_PERMS[user.role] || []).includes(perm);function authorize(perm) {  return (req, res, next) =>    can(req.user, perm) ? next() : res.status(403).json({ error: 'لا تملك الصلاحية ' + perm });} const enrollments = [  { id: 1, traineeId: 1, course: 'تصميم الويب', status: 'pending' },  { id: 2, traineeId: 5, course: 'قواعد البيانات', status: 'pending' },]; app.get('/api/enrollments/:id', authenticate, (req, res) => {  const e = enrollments.find(x => x.id === Number(req.params.id));  const mine = e && e.traineeId === req.user.id;  if (!e || !(mine || can(req.user, 'enrollment:read')))    return res.status(404).json({ error: 'غير موجود' });   // لا نكشف حتى وجوده  res.json(e);}); app.listen(3000);

الآن: سارة ترى سجلها فقط، والسجل الآخر يرد 404 لا 403؛ حتى لا تعرف أنه موجود أصلًا. ونورة (تسجيل) تراه لأنها تملك enrollment:read.

٣لا تثق بما يرسله المستخدم

كل ما في جسم الطلب يكتبه المستخدم، وليس بالضرورة واجهتك. قاعدتان:

الخطأالهجومالحل
الإسناد الجماعي: { ...req.body }المتدرب يرسل "traineeId": 5 فيسجّل باسم غيره، أو "status": "approved" فيعتمد نفسهاختر الحقول المسموحة بالاسم، وخذ هوية المستخدم من req.user لا من الجسم
قيم بلا تحققإرسال حالة «paid» أو «xyz»، أو مبلغ سالبقائمة قيم مسموحة allow-list، ورفض غيرها بـ 400

٤اختبار نظام الصلاحيات

من الخطة: «تنفيذ تقنيات الاختبار والتحقق للتأكد من صحة وفعالية نظام الصلاحيات». الطريقة المنهجية: حوّل كل خانة في مصفوفة الصلاحيات إلى حالة اختبار، ولا تنس الحالات السلبية (من يجب أن يُمنع):

المستخدمالطلبالمتوقعنوع الاختبار
بلا رمزGET /api/enrollments/1401مصادقة
سارة (متدربة)GET /api/enrollments/1200ملكية: سجلها
سارةGET /api/enrollments/2404ملكية: سجل غيرها (IDOR)
سارةPATCH …/1/status approved403تفويض: تعتمد نفسها
نورة (تسجيل)PATCH …/2/status paid400تحقق من المدخلات
نورةPATCH …/2/status approved200المسار المسموح

هذه بالضبط الطريقة التي يختبر بها التحقق الآلي حلولك في التحديات: قائمة طلبات من مستخدمين مختلفين، ونتيجة متوقعة لكل منها. في مشروعك الحقيقي تُكتب كاختبارات آلية تعمل مع كل تعديل (مقرر ضمان الجودة).

توقّع وتحقّق

متدرب يغير رقم السجل في الرابط فيرى بيانات غيره. هذه ثغرة:

لماذا نرد 404 لا 403 على سجل لا يملكه المستخدم؟

من أين نأخذ traineeId عند إنشاء تسجيل؟

أي حالة اختبار سلبية؟

الخلاصة

تحدٍّ برمجي: سدّ الثغرات الثلاث

متقدم

هذا الخادم يصادق المستخدمين، لكن فيه ثلاث ثغرات: IDOR في عرض التسجيل، وإسناد جماعي في الإنشاء، وتغيير الحالة بلا تفويض ولا تحقق. أصلحها.

المطلوب:
  • عرض التسجيل: لصاحبه أو لمن يملك enrollment:read، وغيرهما 404.
  • الإنشاء: traineeId من req.user دائمًا، والحقل course مطلوب.
  • تغيير الحالة: يتطلب enrollment:approve، والقيم المسموحة approved أو rejected فقط (غيرها 400).
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. GET /api/enrollments/1 🔑
  2. GET /api/enrollments/2 🔑
  3. GET /api/enrollments/2 🔑
  4. POST /api/enrollments {"course": "أمن الويب", "traineeId": 5, "status": "approved"} 🔑
  5. PATCH /api/enrollments/2/status {"status": "paid"} 🔑
  6. PATCH /api/enrollments/2/status {"status": "approved"} 🔑
  7. PATCH /api/enrollments/1/status {"status": "approved"} 🔑
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • سارة ترى سجلها ← 200
  • سارة لا ترى سجل غيرها ← 404
  • نورة ترى أي سجل ← 200
  • الإنشاء باسم سارة (traineeId 1) وبحالة pending رغم ما أُرسل
  • حالة غير مسموحة ← 400
  • نورة تعتمد ← approved
  • سارة لا تغير الحالة ← 403
  • لا نسخ لجسم الطلب كله ...req.body

💡 تلميح 1
انسخ فكرة mine || can(...) من مثال IDOR في الدرس.
💡 تلميح 2
في الإنشاء اكتب الحقول بالاسم: { id, traineeId: req.user.id, course: req.body.course, status: 'pending' }.
💡 تلميح 3
قائمة السماح: ['approved', 'rejected'].includes(req.body.status).

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const app = express();app.use(express.json()); // من مصفوفة الصلاحيات (الدرس 16) إلى الكود مباشرةconst ROLE_PERMS = {  trainee:   ['course:read', 'enrollment:create', 'enrollment:read:own'],  trainer:   ['course:read', 'attendance:record'],  registrar: ['course:read', 'enrollment:read', 'enrollment:approve'],  manager:   ['course:read', 'enrollment:read', 'report:finance', 'user:manage'],};const users = [  { id: 1, name: 'سارة', role: 'trainee' },  { id: 2, name: 'نورة', role: 'registrar' },  { id: 3, name: 'أ. فهد', role: 'manager' },  { id: 4, name: 'خالد', role: 'trainer' },];// رموز دخول مبسطة للتجربة؛ في النظام الحقيقي JWT كما في مقرر أطر الويبconst tokens = { 'tok-sara': 1, 'tok-noura': 2, 'tok-fahad': 3, 'tok-khaled': 4 }; // 1) المصادقة: من أنت؟function authenticate(req, res, next) {  const t = (req.headers.authorization || '').replace('Bearer ', '');  const user = users.find(u => u.id === tokens[t]);  if (!user) return res.status(401).json({ error: 'سجّل الدخول أولًا' });  req.user = user;  next();} // 2) التفويض: هل يحق لك؟const can = (user, perm) => (ROLE_PERMS[user.role] || []).includes(perm);function authorize(perm) {  return (req, res, next) =>    can(req.user, perm) ? next() : res.status(403).json({ error: 'لا تملك الصلاحية ' + perm });} const enrollments = [  { id: 1, traineeId: 1, course: 'تصميم الويب', status: 'pending' },  { id: 2, traineeId: 5, course: 'قواعد البيانات', status: 'pending' },]; app.get('/api/enrollments/:id', authenticate, (req, res) => {  const e = enrollments.find(x => x.id === Number(req.params.id));  const mine = e && e.traineeId === req.user.id;  if (!e || !(mine || can(req.user, 'enrollment:read')))    return res.status(404).json({ error: 'غير موجود' });  res.json(e);}); app.post('/api/enrollments', authenticate, authorize('enrollment:create'), (req, res) => {  if (!req.body.course) return res.status(400).json({ error: 'الدورة مطلوبة' });  const e = { id: enrollments.length + 1, traineeId: req.user.id, course: req.body.course, status: 'pending' };  enrollments.push(e);  res.status(201).json(e);}); app.patch('/api/enrollments/:id/status', authenticate, authorize('enrollment:approve'), (req, res) => {  if (!['approved', 'rejected'].includes(req.body.status))    return res.status(400).json({ error: 'الحالة يجب أن تكون approved أو rejected' });  const e = enrollments.find(x => x.id === Number(req.params.id));  if (!e) return res.status(404).json({ error: 'غير موجود' });  e.status = req.body.status;  res.json(e);}); app.listen(3000);
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس