"Chắc ổn" là hai chữ nguy hiểm nhất trong bảo mật. Một chiếc máy bay không cất cánh dựa trên cảm giác của phi công rằng mọi thứ có vẻ đúng — nó cất cánh sau khi phi công đọc qua một checklist từng dòng, mỗi dòng cho ra một câu trả lời dứt khoát: gạt cần này, đèn kia phải sáng. Bảo mật ứng dụng cần đúng loại kỷ luật đó, vì trực giác "trông an toàn" là thứ dối người ta giỏi nhất: khoá xanh trên thanh địa chỉ, header đầy đủ, và một lỗ vẫn nằm im ngay giữa. Bài này tôi lấy một danh sách kiểm bảo mật cho Spring Boot rồi chạy nó lên một ứng dụng thật đang chạy — chính cái blog bạn đang đọc — và nó tìm ra một lỗ có thật.
Đây là ứng dụng của mình, kiểm chỉ-đọc (chỉ đọc header và mã trạng thái, không gửi payload tấn công), không nhắm vào hệ thống của ai khác. Mỗi mục đo 3 lần cách nhau hơn một giây để không chạm giới hạn tần suất; kết quả giống hệt qua cả ba.
Những mục đã đạt
Header bảo mật. Danh sách kiểm bắt đầu bằng năm header mà một app HTTPS nghiêm túc phải gửi. Blog gửi đủ:
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.jsdelivr.net ...
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Mỗi cái đóng một cửa cụ thể. HSTS (một năm, kèm mọi tên miền con) buộc trình duyệt luôn dùng HTTPS, khoá cửa tấn công hạ cấp về HTTP. CSP với script-src không có unsafe-inline là tấm khiên chính chống XSS — dù kẻ tấn công chèn được <script> vào trang, trình duyệt cũng từ chối chạy. X-Frame-Options: SAMEORIGIN chặn clickjacking. nosniff cấm trình duyệt đoán kiểu nội dung. Referrer-Policy giới hạn thông tin rò qua header Referer. Năm cái này là phần khung, và app qua hết.
Chuyển hướng và endpoint quản trị. HTTP bị đẩy sang HTTPS bằng 301, đúng như mong đợi. Quan trọng hơn, tôi thử bốn endpoint actuator — cổng giám sát của Spring Boot mà nếu để hở sẽ lộ biến môi trường, cấu hình, thậm chí cho tải cả ảnh bộ nhớ (heapdump):
/actuator -> 404
/actuator/env -> 404
/actuator/health -> 404
/actuator/heapdump -> 404
Tất cả 404 — không lộ. Đây là một trong những lỗ hổng Spring Boot phổ biến nhất trong thực tế (vô số vụ rò rỉ bắt đầu từ một /actuator/env để công khai), nên thấy nó đóng kín là một mục đạt đáng kể. Điều đáng nói là mặc định của Spring Boot đời mới chỉ phơi /actuator/health ra ngoài, phần còn lại phải cố ý mở — nên đóng kín ở đây là giữ đúng mặc định an toàn chứ không phải thêm rào, đúng mô-típ lặp lại của cả sê-ri.
Trang lỗi. Tôi gọi một URL không tồn tại và kiểm phản hồi có lộ stack trace hay trang "Whitelabel Error" mặc định của Spring không — thứ hay tiết lộ tên lớp, phiên bản framework, cấu trúc bên trong. Kết quả: không lộ gì. Trang 404 sạch.
Mục không đạt: cookie phiên thiếu cờ Secure
Danh sách kiểm chỉ có giá trị nếu nó dám cho ra kết quả "không đạt". Và đây là mục đó:
Set-Cookie: JSESSIONID=...; Path=/; HttpOnly
HttpOnly? CO
Secure? khong
Cookie phiên JSESSIONID có cờ HttpOnly (tốt — JavaScript không đọc được nó, giảm rủi ro trộm phiên qua XSS), nhưng thiếu cờ Secure. Cờ Secure bảo trình duyệt chỉ gửi cookie qua HTTPS, không bao giờ qua HTTP thường. Thiếu nó, về lý thuyết cookie phiên có thể bị gửi qua một kết nối HTTP không mã hoá và bị nghe lén.
Thành thật về mức độ: ở đây rủi ro thực tế thấp, vì app đã bật HSTS — trình duyệt tuân thủ sẽ không bao giờ thử HTTP sau lần đầu, nên gần như không có cơ hội để cookie đi qua kênh thường. Nhưng "rủi ro thấp nhờ một lớp khác" không phải là "đạt". Cờ Secure là phòng thủ theo chiều sâu: nó bảo vệ ngay cả khi HSTS vì lý do nào đó không áp (lần truy cập đầu tiên trước khi HSTS được ghi nhớ, một tên miền con cấu hình lệch). Đây đúng là loại mục mà mắt thường bỏ qua — mọi thứ "trông an toàn", khoá xanh, header đầy đủ — nhưng một dòng kiểm tự động chỉ thẳng ra chỗ còn hở. Với Spring Boot, sửa nó là một dòng cấu hình: server.servlet.session.cookie.secure=true.
Phần danh sách kiểm không đo được từ ngoài
Những mục trên đo được chỉ bằng curl từ bên ngoài — đó là nửa danh sách kiểm nhìn thấy qua HTTP. Nửa còn lại cần đọc cấu hình và mã, và đáng liệt kê để bức tranh đủ:
- CSRF: form thay đổi trạng thái phải có token chống giả mạo yêu cầu. Spring Security bật sẵn cho phiên dựa trên cookie; API dùng token trong header thì miễn CSRF (stateless) — nhưng phải cố ý miễn, không phải vô tình tắt.
- Phân quyền theo vai: mỗi endpoint quản trị phải kiểm quyền ở tầng máy chủ, không dựa vào việc ẩn nút trên giao diện. Một
curlthẳng vào URL admin với tài khoản thường phải trả 403. - Giới hạn tần suất và khoá đăng nhập: các bài trước đã đo — bảo vệ như băm mật khẩu chậm sẽ thành vũ khí nếu không chặn số lần thử.
- Xác thực đầu vào và giới hạn kích thước: chặn thân request khổng lồ, giới hạn số bản ghi sinh ra từ một yêu cầu — cũng đã đo ở phần "bùng nổ tài nguyên".
- Quản lý bí mật và phụ thuộc: khoá không nằm trong mã, quét CVE của thư viện — hai phần riêng của sê-ri này.
Điểm chung: một danh sách kiểm tốt trộn cả mục đo từ ngoài (header, cookie, actuator) lẫn mục đọc từ trong (CSRF, phân quyền, xác thực). Chỉ làm một nửa thì bỏ sót nửa kia — giống đúng bài học SAST và DAST: mỗi góc nhìn thấy một phần, an toàn là chỗ giao của nhiều góc nhìn.
Và một mục cuối, mức lưu ý: header Server: nginx/1.24.0 (Ubuntu) để lộ phiên bản máy chủ — một chút thông tin thừa giúp kẻ tấn công tra cứu lỗ hổng khớp phiên bản. Không nghiêm trọng, nhưng ẩn đi (server_tokens off) là việc dễ. Tổng kết danh sách: tám mục đạt, một mục cần sửa (cờ Secure), một mục lưu ý (lộ phiên bản). Đó chính là giá trị của một danh sách kiểm — nó biến câu "ứng dụng này có an toàn không?" mơ hồ thành một bảng từng dòng đo được, và buộc ra ánh sáng cái mục mà sự tự tin chung sẽ che mất. Không có danh sách kiểm, cookie thiếu Secure kia sẽ nằm im mãi vì mọi thứ khác trông quá ổn.
Mẫu số chung
Cái lõi ở đây — biến phán đoán "chắc ổn" thành một kiểm tra tự động cho ra kết quả nhị phân, và chạy lại đều đặn — là cách mọi hệ trưởng thành thay sự tự tin bằng bằng chứng.
Trong thế giới Java, đây đúng là tinh thần đưa bảo mật thành test: một @SpringBootTest bắn MockMvc vào /actuator/env và khẳng định phải 404, một test kiểm mọi response có đủ header — để cấu hình trôi thì CI đỏ, không đợi ai đó tình cờ curl ra. Cộng thêm mvn dependency-check (OWASP) quét CVE thư viện mỗi build: cùng ý "kiểm tự động, lặp lại được" thay cho "tôi nhớ là đã cấu hình đúng".
Trong Go, cùng phản xạ: govulncheck quét lỗ hổng phụ thuộc, staticcheck/gosec bắt các mẫu nguy hiểm trong CI, và test bảng khẳng định header/route trả đúng mã — tất cả biến "an toàn" thành thứ máy xác nhận được, không phải thứ con người tự trấn an.
Còn ở cơ sở dữ liệu, đây là audit cấu hình tự động: một script (hay pgaudit, các CIS benchmark) kiểm ssl=on, không tài khoản nào quyền thừa, log_connections bật — chạy định kỳ, vì cấu hình DB trôi âm thầm y như cookie thiếu Secure, và "DBA nhớ đã siết rồi" không phải bằng chứng.
Mẫu số chung: một hệ đáng tin không dựa vào việc con người nhớ đã làm đúng, mà biến mỗi điều-phải-đúng thành một kiểm tra máy chạy được, cho kết quả đạt/không-đạt, và chạy lại mỗi lần thay đổi — vì cấu hình luôn trôi, còn trí nhớ và cảm giác "trông ổn" thì không bắt được cái trôi đó. Vì thế câu hỏi đúng không phải "ứng dụng này có an toàn không" mà là "tôi có một danh sách kiểm chạy được nào biến câu đó thành từng dòng đạt/không-đạt không" — vì chỉ cái chạy được mới bắt được đúng cái mục mà sự tự tin chung đang che.
Cả sê-ri quy về đây: từng bài đo một lỗ hổng riêng, còn danh sách kiểm là cách gộp chúng thành một quy trình lặp lại được — và không app nào "đạt hết mãi mãi", vì cấu hình trôi, thư viện cập nhật, endpoint mới mọc ra. Muốn bắt đầu ngay, chạy ba dòng này trên ứng dụng của chính bạn để kiểm ba mục quan trọng nhất:
# 1. Header bao mat co du khong?
curl -sI https://your-app/ | grep -iE 'strict-transport|content-security|x-frame|x-content-type|referrer'
# 2. Actuator co bi lo khong? (mong doi 404/401, KHONG phai 200)
curl -s -o /dev/null -w '%{http_code}\n' https://your-app/actuator/env
# 3. Cookie phien co Secure + HttpOnly khong?
curl -sI https://your-app/login | grep -i set-cookie
Nếu dòng ba hiện Set-Cookie mà không có chữ Secure, bạn vừa tìm ra đúng cái lỗ mà app này cũng mắc — và giờ bạn biết chính xác một dòng cần thêm để đóng nó.