Menu
CoddyTech

כותרות אבטחה

חלק מהיחידה אימות ואבטחה במסלול ה-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();
});
challenge icon

אתגר

קל

דפדפנים מנחשים דברים שתוקפים יכולים לנצל. שלחו שלוש כותרות עם כל תגובה כדי למנוע את הניחושים האלה, והפסיקו לספר לכולם באיזו מסגרת השרת שלכם משתמש.

  1. ב־server.js, הוסיפו תוכנת ביניים לפני כל הנתיבים. היא מגדירה את שלוש הכותרות האלה בכל תגובה, כולל תגובות שגיאה כמו 401:
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
    • Content-Security-Policy: default-src 'self'. רק הדף ב־GET / זקוק לכותרת הזו, אבל אין בעיה להגדיר אותה בכל תגובה.
  2. Express מוסיפה X-Powered-By: Express לכל תגובה. כבו את האפשרות הזו מיד אחרי יצירת האפליקציה.

נסו את זה: לחצו על Run. שלחו GET /notes בלי להתחבר: הסטטוס הוא 401. בכרטיסייה Headers מופיעות הכותרות 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());

// חשבון ה-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 });
});

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');
});
quiz iconבחנו את עצמכם

השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.

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

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