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àoContent-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();
});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.
- 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: nosniffReferrer-Policy: no-referrerContent-Security-Policy: default-src 'self'. Chỉ trang tạiGET /cần header này, nhưng đặt nó trong mọi phản hồi cũng không sao.
- Express thêm
X-Powered-By: Expressvà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');
});
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
1Mật khẩu
Byte và văn bản hexTạo khóa từ mật khẩuSalt mớiKhông bao giờ lưu mật khẩuSo sánh các byte của khóaKiểm tra mật khẩuQuy tắc đăng ký4Quyền sở hữu và an toàn
Chỉ ghi chú của bạnVai trò quản trị viên2Phiên làm việc và cookie
Ghi nhớ bằng cookieTra cứu phiênPhiên đăng nhậpĐịnh tuyến sau khi đăng nhậpTự luyện tập: Playground Express