Menu
CoddyTech

קלט הוא עוין

חלק מהיחידה אימות ואבטחה במסלול ה-Node.js של Coddy. שיעור 24 מתוך 24.

כל דבר שלקוח שולח עלול להיכתב בידי תוקף. גוף בקשה בגודל 50 MB תופס את הזיכרון שלך. הערה שמכילה <script> רצה כקוד בדפדפן של כל מי שצופה בה: זו מתקפת סקריפטים בין אתרים, XSS.

הגבל את מה שאתה מקבל: כל מנתח גוף של Express מקבל limit, וגוף גדול יותר מחזיר 413 Payload Too Large לפני שהנתיב שלך רץ. אחסן את הטקסט בדיוק כפי שנשלח, והימלט ממנו כשאתה מכניס אותו ל-HTML, כדי שהדפדפן יציג את התווים במקום להריץ אותם. html.js מספק לך את escapeHtml.

app.use(express.urlencoded({ limit: '2kb' }));

escapeHtml('<img src=x onerror=steal()>');
// '&lt;img src=x onerror=steal()&gt;'
challenge icon

אתגר

קל

התייחסו לכל דבר שלקוח שולח כאילו ייתכן שנכתב בידי תוקף. דחו גופים גדולים מדי, ודאגו לכך שבעמוד ההערות יוצג HTML ממשתמשים כטקסט פשוט במקום להריץ אותו.

  1. ב־server.js, הגדירו למנתח גוף ה־JSON שבראש הקובץ מגבלה של 10 KB. גוף גדול יותר יקבל סטטוס 413, כלומר מטען גדול מדי, לפני הפעלת נתיב כלשהו. עדיין צריך לשמור הערה בת 9,000 תווים.
  2. ייבאו את escapeHtml מתוך ./html.js. הפונקציה הופכת תווים כמו < ל־&lt;, כך שהדפדפן יציג אותם במקום להריץ אותם. היא פועלת כמו escapeHtml שהכרתם ב־Express Basics.
  3. בעמוד שבנתיב GET /, העבירו את הטקסט של כל הערה ואת שם המשתמש דרך escapeHtml לפני הכנסתם ל־HTML. המשיכו לשמור את ההערות בדיוק כפי שנשלחו. בצעו escaping רק כשהטקסט נכנס ל־HTML.

נסו זאת: לחצו על הפעלה. שלחו POST /signup, ולאחר מכן POST /login, בשניהם עם {"username": "ada", "password": "correct horse"}. שלחו POST /notes עם {"text": "<script>alert('hi')</script>"}: הסטטוס יהיה 201. שלחו GET /: הרשימה תציג את <script>alert('hi')</script> כמילים רגילות, ובמקור העמוד יופיע &lt;script&gt; במקום שבו היה התג. הבדיקות ישלחו גם הערה בת 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();
});

// חשבון המנהל היחיד. הסיסמה שלו מגיעה ממשתני הסביבה.
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');
});

כל השיעורים ביחידה אימות ואבטחה

תרגלו בעצמכם: Express אונליין