طبقات حماية إضافية واختبار الصلاحيات
ما الطبقات الإضافية التي تحمي نظام الصلاحيات، وكيف تختبر أنه يعمل كما صُمم؟ ⏱ نحو 50 دقيقة.
- تطبق مبدأ الدفاع بالعمق بطبقات حماية متتالية.
- تكتشف ثغرة
IDORوتصلحها بشرط الملكية. - تمنع الإسناد الجماعي وتتحقق من المدخلات بقوائم السماح.
- تبني مصفوفة اختبار للصلاحيات بحالات موجبة وسالبة.
١الدفاع بالعمق
وسيطا المصادقة والتفويض ضروريان لكنهما لا يكفيان وحدهما. مبدأ الدفاع بالعمق Defense in Depth: طبقات متتالية، إن أخطأت إحداها أمسكت الأخرى. من الخطة: «تطبيق طبقات إضافية من الحماية مثل التحقق من صحة الطلبات والتحقق من الهوية».
| الطبقة | تمنع |
|---|---|
| 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/1 | 401 | مصادقة |
| سارة (متدربة) | GET /api/enrollments/1 | 200 | ملكية: سجلها |
| سارة | GET /api/enrollments/2 | 404 | ملكية: سجل غيرها (IDOR) |
| سارة | PATCH …/1/status approved | 403 | تفويض: تعتمد نفسها |
| نورة (تسجيل) | PATCH …/2/status paid | 400 | تحقق من المدخلات |
| نورة | PATCH …/2/status approved | 200 | المسار المسموح |
هذه بالضبط الطريقة التي يختبر بها التحقق الآلي حلولك في التحديات: قائمة طلبات من مستخدمين مختلفين، ونتيجة متوقعة لكل منها. في مشروعك الحقيقي تُكتب كاختبارات آلية تعمل مع كل تعديل (مقرر ضمان الجودة).
توقّع وتحقّق
متدرب يغير رقم السجل في الرابط فيرى بيانات غيره. هذه ثغرة:
لماذا نرد 404 لا 403 على سجل لا يملكه المستخدم؟
من أين نأخذ traineeId عند إنشاء تسجيل؟
أي حالة اختبار سلبية؟
الخلاصة
- الدفاع بالعمق:
HTTPS، ومصادقة، وتفويض، وتحقق من المدخلات، وملكية، وسجل. IDOR: سجل بمعرّف يصل إليه غير صاحبه؛ الحل شرط ملكية ورد 404.- لا تثق بجسم الطلب: حقول بالاسم، وهوية من
req.user، وقوائم سماح للقيم. - كل خانة في مصفوفة الصلاحيات حالة اختبار، والحالات السالبة لا تقل أهمية.
تحدٍّ برمجي: سدّ الثغرات الثلاث
متقدمهذا الخادم يصادق المستخدمين، لكن فيه ثلاث ثغرات: IDOR في عرض التسجيل، وإسناد جماعي في الإنشاء، وتغيير الحالة بلا تفويض ولا تحقق. أصلحها.
- عرض التسجيل: لصاحبه أو لمن يملك
enrollment:read، وغيرهما 404. - الإنشاء: traineeId من
req.userدائمًا، والحقل course مطلوب. - تغيير الحالة: يتطلب
enrollment:approve، والقيم المسموحة approved أو rejected فقط (غيرها 400).
👀 اعرض الاستجابات المطلوبة
GET /api/enrollments/1🔑GET /api/enrollments/2🔑GET /api/enrollments/2🔑POST /api/enrollments{"course": "أمن الويب", "traineeId": 5, "status": "approved"} 🔑PATCH /api/enrollments/2/status{"status": "paid"} 🔑PATCH /api/enrollments/2/status{"status": "approved"} 🔑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);