ترويسات الأمان
جزء من قسم المصادقة والأمان في رحلة Node.js على Coddy. الدرس 20 من 24.
تخمن المتصفحات وتشارك المعلومات افتراضيًا: فقد تتعامل مع استجابة نصية على أنها نص برمجي، وتشغّل النصوص البرمجية من أي مكان، وفي المتصفحات الأقدم، ترسل عنوان صفحتك الكامل إلى مواقع أخرى. يمكن لعدد قليل من ترويسات الاستجابة إيقاف ذلك:
X-Content-Type-Options: nosniff: ثق بترويسةContent-Type، ولا تخمّن أبدًا.Referrer-Policy: no-referrer: لا تخبر المواقع الأخرى بالصفحة التي أحالت إليها.Content-Security-Policy: default-src 'self': حمّل النصوص البرمجية والأنماط والصور من هذا الموقع فقط.
يضيف Express أيضًا X-Powered-By: Express، التي تخبر المهاجمين بما يمكنهم تجربته. يزيلها app.disable('x-powered-by'). تضبط برمجية وسيطة واحدة الترويسات لكل استجابة. تضبط هذه البرمجية ترويستين أخريين: تخبر Strict-Transport-Security المتصفح باستخدام HTTPS فقط لمدة عام، وتوقف Permissions-Policy الكاميرا والميكروفون.
app.use((req, res, next) => {
res.set('Strict-Transport-Security', 'max-age=31536000');
res.set('Permissions-Policy', 'camera=(), microphone=()');
next();
});التحدي
سهلتُجري المتصفحات تخمينات يمكن للمهاجمين استغلالها. أرسل ثلاثة ترويسات مع كل استجابة لتعطيل هذه التخمينات، وتوقف عن إخبار الجميع بالإطار الذي يشغّل خادمك.
- في
server.js، أضف برمجية وسيطة قبل جميع المسارات. تضبط هذه البرمجية الترويسات الثلاثة التالية في كل استجابة، بما في ذلك استجابات الأخطاء مثل 401:X-Content-Type-Options: nosniffReferrer-Policy: no-referrerContent-Security-Policy: default-src 'self'. تحتاج صفحةGET /وحدها إلى هذه الترويسة، لكن لا بأس بضبطها في كل استجابة.
- يضيف Express الترويسة
X-Powered-By: Expressإلى كل استجابة. عطّلها مباشرةً بعد إنشاء التطبيق.
جرّب ذلك: اضغط «تشغيل». أرسل GET /notes دون تسجيل الدخول: ستكون الحالة 401. تعرض علامة تبويب الترويسات فيها X-Content-Type-Options: nosniff وReferrer-Policy: no-referrer، ولا تعرض X-Powered-By. أرسل POST /signup، ثم POST /login، مع {"username": "ada", "password": "correct horse"} في كليهما. ثم أرسل GET /: ستظهر لك الصفحة بالترويسات نفسها، بالإضافة إلى Content-Security-Policy: default-src 'self'.
جرّب بنفسك
import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.use(express.json());
// حساب المشرف الوحيد. تأتي كلمة مروره من متغيرات البيئة.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
const sid = randomBytes(24).toString('base64url');
await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ username: user.username });
});
// يعثر على المستخدم المرتبط بملف تعريف ارتباط الجلسة، أو يردّ بالرمز 401.
async function requireUser(req, res, next) {
const { sid } = parseCookies(req.get('cookie'));
const session = sid && (await sessions.findOne({ _id: sid }));
const user = session && (await users.findOne({ _id: session.userId }));
if (!user) {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
next();
}
// يردّ بالرمز 403 ما لم يكن للمستخدم المسجّل دخوله هذا الدور.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Not allowed' });
}
next();
};
}
app.get('/me', requireUser, (req, res) => {
res.json({ username: req.user.username });
});
app.post('/logout', async (req, res) => {
const { sid } = parseCookies(req.get('cookie'));
if (sid) {
await sessions.deleteOne({ _id: sid });
}
res.clearCookie('sid');
res.status(204).end();
});
// ملاحظاتك معروضة في صفحة.
app.get('/', requireUser, async (req, res) => {
const list = await notes.find({ owner: req.user.id }).toArray();
const items = list.map((note) => `<li>${note.text}</li>`).join('');
res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});
// الملاحظات: يقرأ كل مستخدم ملاحظاته ويكتبها فقط.
app.get('/notes', requireUser, async (req, res) => {
res.json(await notes.find({ owner: req.user.id }).toArray());
});
app.post('/notes', requireUser, async (req, res) => {
const note = { owner: req.user.id, text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.get('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
app.delete('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!deletedCount) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).end();
});
// للمشرفين: عدد الحسابات الموجودة.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
يتضمن هذا الدرس اختبارًا قصيرًا. ابدأ الدرس للإجابة عليه وتتبّع تقدمك.
جميع دروس المصادقة والأمان
تدرّب بنفسك: Playground لـ Express