Menu
CoddyTech

Tiêu đề bảo mật

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 20 trên 24.

Trình duyệt mặc định sẽ tự suy đoán và chia sẻ: chúng có thể coi phản hồi dạng văn bản là tập lệnh, chạy tập lệnh từ bất kỳ đâu và, ở các trình duyệt cũ, gửi toàn bộ địa chỉ trang của bạn đến các trang web khác. Một vài tiêu đề phản hồi sẽ tắt những hành vi đó:

  • X-Content-Type-Options: nosniff: tin vào Content-Type, không bao giờ tự suy đoán.
  • Referrer-Policy: no-referrer: không cho các trang web khác biết trang nào đã liên kết đến chúng.
  • Content-Security-Policy: default-src 'self': chỉ tải tập lệnh, kiểu và hình ảnh từ trang web này.

Express cũng thêm X-Powered-By: Express, cho kẻ tấn công biết chúng nên thử gì. app.disable('x-powered-by') sẽ xóa tiêu đề này. Một middleware thiết lập tiêu đề cho mọi phản hồi. Middleware này thiết lập thêm hai tiêu đề: Strict-Transport-Security yêu cầu trình duyệt chỉ dùng HTTPS trong một năm, còn Permissions-Policy tắt camera và micrô.

app.use((req, res, next) => {
    res.set('Strict-Transport-Security', 'max-age=31536000');
    res.set('Permissions-Policy', 'camera=(), microphone=()');
    next();
});
challenge icon

Thử thách

Dễ

Trình duyệt tự đưa ra các phỏng đoán mà kẻ tấn công có thể lợi dụng. Gửi ba header trong mỗi phản hồi để tắt các phỏng đoán đó, đồng thời ngừng tiết lộ framework nào đang chạy trên máy chủ của bạn.

  1. Trong server.js, thêm một middleware trước tất cả các route. Middleware này đặt ba header sau trong mỗi phản hồi, kể cả phản hồi lỗi như 401:
    • X-Content-Type-Options: nosniff
    • Referrer-Policy: no-referrer
    • Content-Security-Policy: default-src 'self'. Chỉ trang tại GET / cần header này, nhưng đặt nó trong mọi phản hồi cũng không sao.
  2. Express thêm X-Powered-By: Express vào mọi phản hồi. Tắt header này ngay sau khi tạo ứng dụng.

Thử nhé: Nhấn Chạy. Gửi GET /notes mà không đăng nhập: trạng thái là 401. Tab Headers hiển thị X-Content-Type-Options: nosniff và Referrer-Policy: no-referrer, đồng thời không có X-Powered-By. Gửi POST /signup, sau đó gửi POST /login, cả hai đều kèm {"username": "ada", "password": "correct horse"}. Tiếp theo, gửi GET /: bạn sẽ nhận được trang cùng các header đó và Content-Security-Policy: default-src 'self'.

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.use(express.json());

// Tài khoản admin 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 });
});

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

// 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à viết 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 admin: số lượng tài khoản hiện có.
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 iconTự kiểm tra

Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.

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

Tự luyện tập: Playground Express