Menu
CoddyTech

Girdi Güvenilmezdir

Coddy'nin Node.js Journey'sinin Kimlik Doğrulama ve Güvenlik bölümünün bir parçası. Ders 24 / 24.

Bir istemcinin gönderdiği her şeyi bir saldırgan yazmış olabilir. 50 MB'lık bir gövde belleğinizi tüketir. <script> içeren bir not, onu görüntüleyen kişinin tarayıcısında kod olarak çalışır: buna , XSS denir.

Kabul ettiklerinizi sınırlandırın: her Express gövde ayrıştırıcısı bir limit alır ve daha büyük bir gövde, rotanız çalışmadan önce 413 Çok Büyük Yük Yanıtı verir. Metni gönderildiği hâliyle saklayın ve HTML'ye eklerken kaçış karakterlerini uygulayın; böylece tarayıcı karakterleri çalıştırmak yerine gösterir. html.js size escapeHtml işlevini sunar.

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

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

Görev

Kolay

Bir istemcinin gönderdiği her şeyi saldırgan tarafından yazılmış olabilecekmiş gibi değerlendirin. Çok büyük gövdeleri reddedin ve notlar sayfasında kullanıcıların gönderdiği HTML'nin çalıştırılmak yerine düz metin olarak gösterilmesini sağlayın.

  1. server.js dosyasında, en üstteki JSON gövde ayrıştırıcısına 10 KB sınırı koyun. Daha büyük bir gövde, herhangi bir rota çalışmadan önce 413 Payload Too Large durum kodunu alır. 9.000 karakterlik bir not yine de kaydedilmelidir.
  2. ./html.js dosyasından escapeHtml işlevini içe aktarın. Bu işlev, < gibi karakterleri &lt; biçimine dönüştürür; böylece tarayıcı bunları çalıştırmak yerine gösterir. Express Basics dersinde gördüğünüz escapeHtml gibi çalışır.
  3. GET / sayfasında, her notun metnini ve kullanıcı adını HTML'ye eklemeden önce escapeHtml işlevinden geçirin. Notları gönderildikleri hâliyle kaydetmeye devam edin. Yalnızca metin HTML'ye eklenirken kaçış uygulayın.

Deneyin: Çalıştır'a basın. POST /signup, ardından POST /login isteğini gönderin; her ikisinde de {"username": "ada", "password": "correct horse"} kullanın. POST /notes isteğini {"text": "<script>alert('hi')</script>"} ile gönderin: durum kodu 201 olmalıdır. GET / isteğini gönderin: listede <script>alert('hi')</script> düz metin olarak görünmeli ve sayfa kaynağında etiketin olduğu yerde &lt;script&gt; bulunmalıdır. Denetimler sizin için ayrıca 11.000 karakterlik bir not gönderip 413 durum kodunu bekler; bu nedenle böyle bir notu kendiniz yazmanız gerekmez.

Kendin dene

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());

// Tarayıcıya her yanıtta dikkatli olmasını söyleyen başlıklar.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

// Tek admin hesabı. Parolası ortamdan alınır.
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 });
});

// Kullanıcı adına göre başarısız girişler: { 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 });
});

// Oturum çerezine ait kullanıcıyı bulur veya 401 yanıtını verir.
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();
}

// Giriş yapmış kullanıcı bu role sahip değilse 403 yanıtını verir.
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();
});

// Notlarınızın sayfa olarak gösterimi.
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>`);
});

// Notlar: Her kullanıcı yalnızca kendi notlarını okur ve yazar.
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();
});

// Yöneticiler için: Toplam hesap sayısı.
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');
});

Kimlik Doğrulama ve Güvenlik bölümündeki tüm dersler

Kendi başına pratik yap: Express playground