المدخلات غير موثوقة
جزء من قسم المصادقة والأمان في رحلة Node.js على Coddy. الدرس 24 من 24.
قد يكون أي شيء يرسله العميل مكتوبًا بواسطة مهاجم. فالجسم بحجم 50 MB يستنزف ذاكرتك. والملاحظة التي تحتوي على <script> تُنفَّذ كتعليمة برمجية في متصفح كل من يعرضها: وهذا هو البرمجة النصية عبر المواقع، XSS.
حدِّد ما تقبله: يقبل كل محلّل أجسام في Express خيار limit، ويؤدي الجسم الأكبر إلى استجابة 413 حمولة كبيرة جدًا قبل تشغيل المسار. خزّن النص كما أُرسل تمامًا، واهربه عند وضعه في HTML، لكي يعرض المتصفح المحارف بدلًا من تنفيذها. يوفّر لك html.js الدالة escapeHtml.
app.use(express.urlencoded({ limit: '2kb' }));
escapeHtml('<img src=x onerror=steal()>');
// '<img src=x onerror=steal()>'التحدي
سهلتعامل مع كل ما يرسله العميل على أنه قد يكون مكتوبًا من مهاجم. ارفض الطلبات التي تكون أجسامها كبيرة جدًا، واجعل صفحة الملاحظات تعرض HTML الذي يرسله المستخدمون كنص عادي بدلًا من تنفيذه.
- في
server.js، عيّن حدًا قدره 10 KB لمحلّل أجسام JSON في أعلى الملف. عندئذٍ، سيُعاد رمز الحالة 413 Payload Too Large للجسم الأكبر قبل تشغيل أي مسار. ويجب أن تظل الملاحظة المكوّنة من 9,000 حرف قابلة للحفظ. - استورد
escapeHtmlمن./html.js. فهو يحوّل الأحرف مثل<إلى<، كي يعرضها المتصفح بدلًا من تنفيذها. وهو يعمل مثلescapeHtmlالذي تعرّفت إليه في أساسيات Express. - في الصفحة عند
GET /، مرّر نص كل ملاحظة واسم المستخدم عبرescapeHtmlقبل إدخالهما في HTML. احفظ الملاحظات تمامًا كما أُرسلت. لا تهرّب النص إلا عند إدخاله في HTML.
جرّب ذلك: اضغط «العب». أرسل POST /signup، ثم POST /login، وكلاهما مع {"username": "ada", "password": "correct horse"}. أرسل POST /notes مع {"text": "<script>alert('hi')</script>"}: يجب أن يكون رمز الحالة 201. أرسل GET /: تعرض القائمة <script>alert('hi')</script> على هيئة كلمات عادية، ويحتوي مصدر الصفحة على <script> في موضع الوسم. ترسل الاختبارات أيضًا ملاحظة من 11,000 حرف نيابةً عنك، وتتوقع رمز الحالة 413، لذا لن تحتاج إلى كتابة واحدة.
جرّب بنفسك
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.disable('x-powered-by');
app.use(express.json());
// ترويسات تطلب من المتصفح توخي الحذر مع كل استجابة.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// حساب admin الوحيد. تأتي كلمة مروره من البيئة.
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 });
});
// محاولات تسجيل الدخول الفاشلة لكل اسم مستخدم: { count, since }.
const failures = new Map();
const WINDOW_MS = 60 * 1000;
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
let failed = failures.get(username);
if (failed && Date.now() - failed.since >= WINDOW_MS) {
failed = undefined;
}
if (failed && failed.count >= 5) {
res.set('Retry-After', String(Math.ceil((failed.since + WINDOW_MS - Date.now()) / 1000)));
return res.status(429).json({ error: 'Too many failed logins. Try again later.' });
}
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
failures.set(username, { count: (failed?.count ?? 0) + 1, since: failed?.since ?? Date.now() });
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