Menu
CoddyTech

Đầu vào luôn tiềm ẩn nguy cơ

Thuộc phần Xác thực và bảo mật trong hành trình Node.js của Coddy. Bài học 24 trên 24.

Bất cứ thứ gì máy khách gửi đều có thể do kẻ tấn công viết. Một phần nội dung có dung lượng 50 MB sẽ chiếm dụng bộ nhớ của bạn. Một ghi chú chứa <script> sẽ chạy dưới dạng code trong trình duyệt của bất kỳ ai xem ghi chú đó: đó là tấn công XSS (cross-site scripting).

Hãy giới hạn nội dung bạn chấp nhận: mọi bộ phân tích nội dung body của Express đều nhận limit, và nội dung lớn hơn sẽ nhận phản hồi 413 Payload Too Large trước khi route chạy. Lưu nguyên văn nội dung như lúc nhận được, rồi thoát ký tự đặc biệt khi đưa nội dung vào HTML để trình duyệt hiển thị các ký tự đó thay vì chạy chúng. html.js cung cấp escapeHtml.

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

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

Thử thách

Dễ

Hãy xem mọi thứ máy khách gửi là nội dung có thể do kẻ tấn công viết. Từ chối các nội dung gửi lên quá lớn, đồng thời hiển thị HTML do người dùng gửi trên trang ghi chú dưới dạng văn bản thuần thay vì chạy nó.

  1. Trong server.js, đặt giới hạn 10 KB cho trình phân tích nội dung JSON ở đầu tệp. Nội dung lớn hơn sẽ nhận trạng thái 413 Payload Too Large trước khi bất kỳ tuyến nào chạy. Ghi chú dài 9,000 ký tự vẫn phải được lưu.
  2. Nhập escapeHtml từ ./html.js. Hàm này đổi các ký tự như < thành &lt;, để trình duyệt hiển thị chúng thay vì chạy chúng. Hàm này hoạt động giống như escapeHtml bạn đã gặp trong Express Basics.
  3. Trên trang tại GET /, truyền văn bản của từng ghi chú và tên người dùng qua escapeHtml trước khi đưa chúng vào HTML. Lưu ghi chú đúng như nội dung đã nhận. Chỉ escape khi văn bản được đưa vào HTML.

Thử nhé: Nhấn Chạy. Gửi POST /signup, sau đó gửi POST /login, cả hai đều có {"username": "ada", "password": "correct horse"}. Gửi POST /notes với {"text": "<script>alert('hi')</script>"}: trạng thái 201. Gửi GET /: danh sách hiển thị <script>alert('hi')</script> dưới dạng văn bản thuần, còn mã nguồn trang có &lt;script&gt; ở vị trí của thẻ. Các bước kiểm tra cũng gửi một ghi chú dài 11,000 ký tự thay bạn và mong đợi trạng thái 413, nên bạn không cần tự nhập ghi chú đó.

Tự làm thử

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

// Các header yêu cầu trình duyệt thận trọng với mọi phản hồi.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

// Tài khoản quản trị duy nhất. Mật khẩu của tài khoản này lấy từ môi trường.
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 });
});

// Số lần đăng nhập thất bại theo tên người dùng: { 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 });
});

// Tìm người dùng qua cookie phiên, nếu không có thì trả về 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();
}

// Trả về 403 nếu người dùng đã đăng nhập không có vai trò này.
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();
});

// Hiển thị ghi chú của bạn trên một trang.
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>`);
});

// Ghi chú: mỗi người dùng chỉ đọc và ghi ghi chú của mình.
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();
});

// Dành cho quản trị viên: số lượng tài khoản.
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');
});

Tất cả bài học trong Xác thực và bảo mật

Tự luyện tập: Playground Express