Cái báo cháy trong bếp gào lên inh ỏi mỗi lần bạn nướng cháy miếng bánh mì — ồn điếc tai, nhưng vô hại. Trong khi đó khí CO rò từ bình nóng lạnh thì không màu, không mùi, không một tiếng động, và nó giết người trong giấc ngủ. Bảo mật ứng dụng có đúng cái tính trớ trêu đó: thứ gào thét nhất — trang lỗi đỏ lòm, mã 403, dấu vết ngăn xếp — thường chỉ là bánh mì cháy; còn lỗ hổng thật thì lặng như CO, không log, không cảnh báo, cho tới hôm ai đó bước vào bằng tài khoản của bạn. Cả chặng bảo mật này, gói lại trong một bài, xoay quanh việc học cách không tin vào tiếng động. Mười bài, gom thành thứ dùng được.

Sáu kết quả đáng nhớ

Người dùng vai USER gọi được phương thức @PreAuthorize("hasRole('ADMIN')") (bài 41) — chỉ cần gọi vòng qua chính lớp đó. Không lỗi, không log, không cảnh báo.

BCrypt cắt ở byte thứ 72 (bài 40). Hai mật khẩu khác hẳn nhau nhưng trùng 72 byte đầu được coi là một. Với tiếng Việt có dấu, 72 byte chỉ khoảng 30–40 ký tự.

Gửi đúng token CSRF vẫn 403 (bài 42). Spring Security 6 đổi ba thứ cùng lúc: xoay token lúc đăng nhập, hoãn sinh token, và bộ xử lý XOR.

403 hiện ra thành 401 (bài 42). CSRF hỏng trả 403, rồi dispatch /error biến nó thành 401. Mã bạn thấy không phải mã ban đầu.

Cấu hình HSTS mà header không xuất hiện (bài 45). Spring chỉ gửi nó cho request HTTPS, và sau nginx thì ứng dụng chỉ thấy HTTP nếu thiếu X-Forwarded-Proto.

MockMvc nói 403, máy chủ thật trả 405 (bài 47). Cùng một đoạn mã, cùng một request.

Bốn chỗ proxy làm chú thích biến mất

Đây là mạch xuyên suốt cả sê-ri, không riêng chặng này:

Chú thích Tự gọi thì Bài
@Transactional không có giao dịch 26
@Cacheable cache không trúng 35
@PreAuthorize phân quyền bị bỏ qua 41
trường public trên bean đọc ra null 35

Cùng một nguyên nhân: Spring cài đặt bằng proxy, và lời gọi từ bên trong cùng lớp không đi qua proxy.

Ba cái đầu đều im lặng. Cái thứ ba là lỗ hổng bảo mật — đúng cái CO không mùi.

Quy tắc rút ra: đặt chú thích ở ranh giới, không ở bên trong. Và khi cần kiểm ở tầng sâu, tách sang bean khác — việc đó thường lộ ra rằng lớp đang làm hai việc.

Danh sách kiểm trước khi lên sản xuất

Xác thực

  • Mật khẩu băm bằng BCrypt cost ≥ 10 hoặc Argon2, qua DelegatingPasswordEncoder
  • Giới hạn số lần thử đăng nhập, đếm theo cửa sổ chứ không chặn cứng theo IP
  • Cùng một thông báo cho "sai mật khẩu" và "không có tài khoản", và cùng thời gian phản hồi
  • Không mật khẩu nào trong log

Phân quyền

  • Mọi endpoint có luật, kết bằng anyRequest().authenticated()
  • Quyền trên dữ liệu, không chỉ trên endpoint — tốt nhất là đưa vào truy vấn
  • Không có @PreAuthorize nào bị bỏ qua vì tự gọi

Phiên và token

  • Cookie có HttpOnly, Secure, SameSite=Lax
  • CSRF bật cho mọi thứ dùng cookie; tắt có chọn lọc cho API dùng header
  • JWT có exp ngắn, kiểm iss và aud, thuật toán được ghim

Truyền tải

  • HTTPS bắt buộc, HSTS thật sự được gửi (kiểm bằng curl -sI trên URL https)
  • X-Forwarded-Proto, X-Forwarded-Host, X-Real-IP được nginx chuyển tiếp
  • CSP không có unsafe-inline trong script-src

Lộ thông tin

  • Actuator không ra Internet, include liệt kê tường minh
  • Dấu vết ngăn xếp tắt trong phản hồi
  • Thông báo lỗi không chứa SQL, tên ràng buộc, hay đường dẫn tệp

Đầu vào

  • Mọi @RequestBody có @Valid
  • Mọi đầu vào sinh nhiều bản ghi có trần, kiểm trước khi ghi
  • Tệp tải lên: giới hạn kích thước, không tin tên tệp, không tin Content-Type, làm sạch SVG
  • Chuyển hướng chỉ lấy phần đường dẫn, bắt đầu bằng đúng một dấu gạch chéo

Phụ thuộc

  • Quét trong CI với ngưỡng làm đỏ build
  • Cập nhật tự động bật sẵn

Ba nguyên tắc

Nhiều lớp, không một lớp. Phân quyền theo URL cộng phân quyền mức phương thức cộng điều kiện trong truy vấn. Bỏ sót một tầng vẫn còn hai tầng. Bài 41 cho thấy vì sao: một tầng có thể biến mất mà không ai biết.

Mặc định an toàn. Spring Security khoá mọi thứ khi bạn thêm phụ thuộc (bài 39), và đó là mặc định đúng — bạn phải khai tường minh cái gì công khai. Áp dụng cùng nguyên tắc cho mã của mình: danh sách cho phép, không phải danh sách chặn.

Đo, đừng tin cấu hình. Sáu kết quả ở đầu bài đều là chỗ cấu hình trông đúng mà hành vi thật khác. Một lệnh curl kiểm được nhiều hơn một giờ đọc lại mã.

Điều tôi rút ra từ chặng này

Ba trong sáu bất ngờ ở trên không phải lỗ hổng — chúng là thứ trông như hỏng mà thật ra đang hoạt động đúng (HSTS vắng mặt, 403 thành 401, CSRF từ chối token cũ). Và một cái trông như hoạt động bình thường lại là lỗ hổng thật (tự gọi bỏ qua @PreAuthorize).

Đó là điều làm bảo mật khó: triệu chứng không tương ứng với mức nghiêm trọng. Thứ ồn ào thường vô hại, thứ nguy hiểm thường im lặng — bánh mì cháy gào lên, còn CO thì không.

Cách duy nhất tôi biết để đối phó là không dựa vào triệu chứng: có danh sách kiểm, chạy nó định kỳ, và thử bằng tài khoản của người khác. Mà muốn bắt đầu ngay thì chạy một dòng, kiểm phần rẻ nhất của cả danh sách:

curl -sI https://ung-dung-cua-ban.com/ | grep -icE "content-security|strict-transport|x-frame|x-content-type|referrer"

Con số dưới 5 nghĩa là bạn thiếu header bảo mật cơ bản — và đó đúng là cái "máy dò CO" rẻ tiền mà mọi nhà nên có trước.

Mẫu số chung

Chặng này mang nhãn Spring Security, nhưng ba nguyên tắc ở trên không thuộc về framework nào — chúng là giáo lý chung của cả ngành bảo mật, và OWASP đã viết ra từ lâu.

  • Phòng thủ nhiều lớp (defense in depth) là nguyên tắc số một ở mọi nơi: không bao giờ đặt cược vào một tầng duy nhất, vì tầng nào cũng có thể hỏng hoặc biến mất. Cái bẫy proxy-tự-gọi của Spring chỉ là một ví dụ của một họ lớn hơn — mọi cơ chế chặn ngầm của framework đều có mép mà phép màu lặng lẽ không chạy: decorator của Python, middleware của Express, Proxy của JS đều có chỗ "nó không áp dụng mà không báo". Biết vậy thì bạn không đặt cả phần phân quyền vào đúng một cơ chế như thế.
  • Mặc định an toàn, dùng danh sách cho phép là bài học lặp lại từ chính chặng này (Actuator, che mật khẩu) tới mọi hướng dẫn làm cứng hệ thống: liệt kê "cái gì được mở" thì một thứ lạ mặc định bị khoá; liệt kê "cái gì phải chặn" thì một thứ lạ mặc định lọt.
  • Triệu chứng không đo được mức nghiêm trọng là sự thật xuyên suốt an ninh phần mềm, đúng cái bài govulncheck đã chạm tới: lỗi gào thét to nhất (500, stack trace) thường chỉ là thông tin, còn vụ rò rỉ thật (vượt quyền, lộ thời gian phản hồi) thì im lặng.

Sợi chỉ chung đáng mang theo: bạn không thể dựa vào việc nhận ra một lỗ hổng, vì lỗ hổng nguy hiểm nhất được thiết kế để không gây tiếng động — nên an ninh không phải việc phản ứng với triệu chứng, mà là việc chủ động: một danh sách kiểm chạy định kỳ, quét phụ thuộc trong CI, và phép thử đơn giản nhất nhưng hay bị quên — đăng nhập bằng tài khoản của người khác và thử chạm vào dữ liệu của bạn. Ở Spring, Rails, Express hay Django, cái cứu bạn không phải là cấu hình trông đúng, mà là thói quen đi kiểm khi không có gì kêu cả.

Ngày mai bắt đầu chặng cuối: kiểm thử và đưa vào sản xuất. Mở đầu bằng kim tự tháp test và các lát cắt của Spring Boot.