🧱 HTTPS, phân quyền cơ bản và validate input ở server

By Better Wise

Vì sao ba lớp chặn này quyết định trước khi deploy

Ba lesson trước đã chỉ ra chỗ AI hay viết ẩu: nối chuỗi SQL, render dữ liệu thẳng vào HTML, thao tác thiếu CSRF token. Lesson ngay trước đó dọn secrets ra khỏi repo và đưa vào biến môi trường. Nhưng secrets sạch mà đường truyền trần trụi, API thì ai đăng nhập cũng gọi được, và body request thì tin sạch, thì mọi thứ vẫn đổ. Hôm nay chúng ta xử lý đúng ba chỗ đó.

Sau lesson này, bạn cần làm được:

  • Bật HTTPS cho app, hiểu vì sao cookie session phải có Secure, HttpOnly, SameSite.
  • Thêm kiểm tra quyền ở server thay vì tin vào việc ẩn nút trên giao diện.
  • Đặt validate ở server và giải thích được vì sao validate phía client chỉ là trang trí.
  • Nhận ra ba kiểu code AI hay sinh thiếu ở phần này.
  • Có phản xạ kiểm tra cả ba lớp trước khi bấm deploy.

Bức tranh tổng thể: một request phải qua mấy cửa

flowchart TD
  A["🌐<br/>Request từ client"] --> B["🔒<br/>HTTPS + HSTS"]
  B --> C["🪪<br/>Xác thực: session hợp lệ"]
  C --> D["🧑‍⚖️<br/>Phân quyền: đúng chủ sở hữu hoặc role"]
  D --> E["🧹<br/>Validate schema"]
  E --> F["⚙️<br/>Handler + query tham số hoá"]

Đọc từ trái sang phải. AI rất giỏi viết cửa số 1 và số 5, hay bỏ cửa số 2, số 3 và số 4.

HTTPS: bảo vệ đường truyền, không bảo vệ dữ liệu

Sự cố quen thuộc: một bạn dựng app quản lý đơn hàng nội bộ, deploy lên hosting, mở bằng http://. Session token bay trong header dạng plain text. Ngồi cùng wifi văn phòng, một người mở Wireshark là thấy token, copy vào Postman và đóng vai bạn đó cả buổi chiều. Code chẳng có lỗi logic nào. Lỗi nằm ở chỗ không có HTTPS và cookie thiếu cờ bảo vệ.

Đây là đoạn AI sinh ra, và đây là bản bạn cần trước khi deploy:

// AI sinh: chỉ có mỗi dòng này
app.use(session({ secret: process.env.SESSION_SECRET }));

// Bản sửa
app.set('trust proxy', 1); // khi chạy sau Nginx, Render, Vercel...
app.use((req, res, next) => {
  if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
    return res.redirect(308, `https://${req.headers.host}${req.originalUrl}`);
  }
  next();
});
app.use(session({
  secret: process.env.SESSION_SECRET,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax' },
}));

Ba cờ cookie làm ba việc khác nhau: Secure cấm trình duyệt gửi cookie qua HTTP thường, HttpOnly chặn JavaScript đọc cookie (giảm thiệt hại nếu có XSS), SameSite hạn chế request cross-site. Thêm helmet() để có HSTS: header này bắt trình duyệt chỉ dùng HTTPS với domain của bạn trong các lần sau, kể cả khi người dùng gõ http://.

Nhắc lại cho rõ: HTTPS không cứu được SQL injection. Dữ liệu vẫn đi vào database nguyên vẹn, chỉ là trên đường truyền nó được mã hoá.

Phân quyền: giao diện không phải là hàng rào

Một lỗi tôi gặp đi gặp lại: AI sinh trang admin, ẩn menu quản trị nếu user.role !== 'admin', và thế là hết. Nhưng API /api/admin/users vẫn mở toang. Kẻ tấn công không dùng menu, họ dùng curl.

Lỗi thứ hai, nguy hiểm hơn vì khó thấy: endpoint chi tiết đơn hàng chỉ lọc theo id.

// AI sinh: đăng nhập rồi là xem được đơn của người khác
app.get('/api/orders/:id', requireLogin, async (req, res) => {
  const { rows } = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
  if (!rows[0]) return res.sendStatus(404);
  res.json(rows[0]);
});

// Sửa: quyền được kiểm tra ngay trong điều kiện query
app.get('/api/orders/:id', requireLogin, async (req, res) => {
  const { rows } = await db.query(
    'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
    [req.params.id, req.user.id]
  );
  if (!rows[0]) return res.sendStatus(404); // không tiết lộ id có tồn tại hay không
  res.json(rows[0]);
});

Kiểu lỗi này gọi là IDOR (Insecure Direct Object Reference): đổi id trên URL và đọc được dữ liệu của người khác. Nguyên tắc sửa rất gọn: kiểm tra quyền ngay trong câu query, đừng lấy dữ liệu ra rồi mới so sánh trong if. Query lọc theo chủ sở hữu thì không có đường vòng.

Với route quản trị, đặt middleware kiểm tra role và mặc định từ chối:

function requireRole(role) {
  return (req, res, next) => {
    if (!req.user || req.user.role !== role) return res.sendStatus(403);
    next();
  };
}
app.get('/api/admin/users', requireLogin, requireRole('admin'), handler);

Một chi tiết nữa: role phải đọc từ session hoặc database, tuyệt đối không đọc từ req.body hay header do client gửi lên.

Validate ở server: chỉ tin dữ liệu đã qua cửa kiểm tra

Validate phía client giúp người dùng gõ đúng, nhưng nó chạy trên máy họ. curl, Postman, hay devtools đều bỏ qua được. Server phải kiểm tra lại từ đầu.

Kiểu code AI hay sinh nhất là gán thẳng body:

// AI sinh: client gửi {"role":"admin"} là xong
const user = await User.create(req.body);

Đây là mass assignment. Bản sửa dùng schema chặt, có .strict() để từ chối field lạ:

const Signup = z.object({
  email: z.string().email().max(254),
  age: z.number().int().min(13).max(120),
}).strict();

app.post('/api/signup', async (req, res) => {
  const parsed = Signup.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ error: 'Dữ liệu không hợp lệ' });
  const user = await User.create({ ...parsed.data, role: 'member' });
  res.status(201).json({ id: user.id });
});

Bốn câu hỏi để tự soát phần validate: kiểu dữ liệu đúng chưa, độ dài có bị chặn trên không, giá trị có nằm trong danh sách cho phép không, và field nhạy cảm (role, is_admin, owner_id) có bị client ghi đè không. Luôn dùng danh sách cho phép, đừng dùng danh sách chặn.

Kiểm tra nhanh

Trên production, request HTTP thường được redirect 308 sang _____. Lỗi cho phép đọc bản ghi theo id mà không kiểm tra chủ sở hữu gọi là _____. Validate phải chạy ở _____ vì kiểm tra phía client có thể bị bỏ qua. Gán thẳng req.body vào create hoặc update gây lỗi _____. Nên trả về _____ thay vì 403 khi bản ghi không thuộc quyền người dùng.

Ba câu hỏi của người review khó tính

  • "Nếu tôi xoá giao diện đi thì API này còn chặn được không?" Nếu câu trả lời là không, quyền đang nằm sai chỗ. Ẩn nút không phải phân quyền.
  • "Câu query này có điều kiện chủ sở hữu chưa?" Mọi endpoint nhận :id đều phải trả lời được câu này. Đây là chỗ AI sai nhiều nhất và cũng là chỗ dễ soát nhất.
  • "Field nào trong body là field nhạy cảm?" Liệt kê ra trước khi tin req.body. Nếu danh sách đó không rỗng, dùng schema, đừng spread.

Vài sai lầm hay gặp: bật HTTPS nhưng quên trust proxy nên redirect thành vòng lặp vô hạn; đặt secure: true rồi test ở local bằng http://localhost và tưởng session hỏng; validate ở client rồi thôi; kiểm tra role bằng req.query.role.

Chốt lại và bước tiếp theo

Ba lớp chặn, ba câu hỏi khác nhau: HTTPS trả lời "dữ liệu có bị nghe lén trên đường truyền không", phân quyền trả lời "người này có được phép chạm vào thứ này không", validate trả lời "dữ liệu này có đúng hình dạng không". Thiếu lớp nào cũng đủ để một sự cố xảy ra, và cả ba đều là chỗ AI viết thiếu nếu bạn không soát.

Front: HTTPS bảo vệ được gì và không bảo vệ được gì? Back: Bảo vệ dữ liệu trên đường truyền khỏi nghe lén và sửa đổi; không thay thế validate hay phân quyền.

Front: Cookie session nên có ba cờ nào? Back: Secure, HttpOnly và SameSite.

Front: HttpOnly chặn được gì? Back: Chặn JavaScript đọc cookie, giảm thiệt hại khi có XSS.

Front: Vì sao ẩn nút trên giao diện không phải là phân quyền? Back: Kẻ tấn công gọi thẳng API bằng curl hoặc Postman, không đi qua giao diện.

Front: IDOR là gì? Back: Lỗi cho phép truy cập object theo id mà không kiểm tra quyền sở hữu.

Front: AI hay sinh thiếu gì ở endpoint chi tiết đơn hàng? Back: Thiếu điều kiện user_id trong query, chỉ lọc theo id.

Front: Nên đặt kiểm tra quyền ở đâu? Back: Ngay trong điều kiện query hoặc middleware phía server, trước khi trả dữ liệu.

Front: Trả 404 thay vì 403 khi bản ghi không thuộc quyền để làm gì? Back: Không tiết lộ id đó có tồn tại hay không.

Front: Validate phía client có đủ không? Back: Không, client bỏ qua được, server phải validate lại toàn bộ.

Front: Mass assignment là gì? Back: Gán thẳng req.body vào create hoặc update, cho phép client đặt field nhạy cảm như role.

Front: Vì sao dùng whitelist thay vì blacklist khi validate? Back: Whitelist chỉ nhận kiểu, độ dài và giá trị đã định nghĩa, blacklist luôn sót trường hợp.

Front: app.set('trust proxy', 1) để làm gì? Back: Để Express đọc đúng protocol khi chạy sau reverse proxy, nhờ đó redirect HTTPS mới đúng.

Front: HSTS là gì? Back: Header buộc trình duyệt chỉ dùng HTTPS với domain đó ở các lần truy cập sau.

Lesson sau là checklist review code AI trước khi deploy: gom toàn bộ những điểm đã học từ module 1 đến đây thành một danh sách ngắn để bạn chạy qua trước mỗi lần deploy.