99 Ngày Spring — Ngày 50: Security best practices
Mốc 50/99 khép lại mười ngày bảo mật. Hôm nay không thêm tính năng mới mà trả lời ba câu hỏi bạn sẽ gặp thật lúc chuẩn bị lên production — và cả ba đều là chỗ người ta hay làm cho hết lỗi thay vì làm cho đúng: khi nào CSRF thật sự cần, CORS thực sự bảo vệ ai, và cái checklist cuối cùng trước khi bấm deploy.

CSRF — hỏi đúng câu thì khỏi học thuộc
Rất nhiều người tắt CSRF chỉ vì gọi POST bị 403, rồi để vậy luôn. Nhưng câu hỏi thật không phải "bật hay tắt" mà là: trình duyệt có tự gửi thông tin đăng nhập kèm request không? Trả lời được câu đó là suy ra được cả bảng, khỏi học thuộc trường hợp nào.
// CSRF — câu hỏi thật KHÔNG phải "bật hay tắt", mà là "TRÌNH DUYỆT CÓ TỰ GỬI
// THÔNG TIN ĐĂNG NHẬP KÈM REQUEST KHÔNG?"
// CSRF hoạt động được là nhờ ĐÚNG MỘT điều: trình duyệt TỰ ĐỘNG đính kèm cookie.
// Bạn đang đăng nhập ngân hàng. Bạn mở một trang lạ. Trang đó có:
// <form action="https://bank.vn/chuyen-tien" method="post"> ... </form>
// Trình duyệt gửi kèm cookie phiên của bạn. Ngân hàng thấy request HỢP LỆ.
// ➜ TỪ ĐÓ SUY RA CẢ BẢNG, khỏi học thuộc:
// Phiên bằng cookie (web truyền thống, Thymeleaf) → CẦN CSRF. Bật (mặc định).
// JWT trong header Authorization → KHÔNG cần. Trình duyệt
// không tự thêm header đó.
// JWT lưu trong cookie → CẦN LẠI. Cookie thì tự gửi.
// API cho ứng dụng di động (không cookie) → không liên quan.
http.csrf(c -> c.disable()); // ✅ HỢP LỆ khi và chỉ khi KHÔNG có cookie phiên
http.csrf(Customizer.withDefaults()); // ✅ mặc định cho ứng dụng có session
// ❗ Nhiều người tắt CSRF chỉ vì "gọi POST bị 403" rồi để vậy luôn.
// Nếu ứng dụng của bạn dùng session cookie thì đó là một lỗ hổng thật,
// không phải một dòng cấu hình cho tiện.
// ⭐ SameSite=Lax (mặc định của trình duyệt hiện đại) đã chặn phần lớn CSRF,
// nhưng nó là phòng thủ của TRÌNH DUYỆT, không phải của bạn — người dùng
// dùng trình duyệt cũ thì bạn vẫn phải tự lo. Phòng thủ nhiều lớp.CSRF sống được nhờ đúng một điều: trình duyệt tự động đính kèm cookie — nên nơi nào không có cookie thì nơi đó không có CSRF.
JWT trong header
Authorizationthì không cần CSRF; nhưng JWT lưu trong cookie thì cần lại (đúng như Ngày 46 đã nói).Tắt CSRF trên ứng dụng dùng session cookie là một lỗ hổng thật, không phải một dòng cấu hình cho tiện.
SameSite=Laxđã chặn phần lớn CSRF, nhưng đó là phòng thủ của trình duyệt chứ không phải của bạn — hãy phòng thủ nhiều lớp.
CORS — nó không bảo vệ server của bạn
Đây là thứ bị hiểu nhầm nhiều nhất trong bảo mật web. CORS không bảo vệ server của bạn — nó bảo vệ người dùng khỏi việc một trang web đọc trộm dữ liệu từ trang khác. curl và Postman chưa bao giờ bị CORS chặn, vì chúng không phải trình duyệt.
// CORS — thứ bị hiểu nhầm nhiều nhất trong bảo mật web.
// ⭐ SỰ THẬT ĐẦU TIÊN: CORS KHÔNG BẢO VỆ SERVER CỦA BẠN.
// Nó bảo vệ NGƯỜI DÙNG khỏi việc một trang web đọc trộm dữ liệu từ trang khác.
// curl, Postman, script Python — KHÔNG có thứ nào bị CORS chặn.
// Nới lỏng CORS không "mở cửa cho hacker"; nó chỉ cho phép JS ở origin khác ĐỌC kết quả.
// ⭐ SỰ THẬT THỨ HAI: request VẪN CHẠY TỚI SERVER.
// Trình duyệt gửi request, server xử lý (kể cả GHI DỮ LIỆU), rồi trình duyệt
// mới CHẶN PHẦN ĐỌC KẾT QUẢ. Nghĩ CORS chặn được request là hiểu sai.
@Bean
CorsConfigurationSource cors() {
var c = new CorsConfiguration();
c.setAllowedOrigins(List.of("https://app.congty.vn")); // ❗ liệt kê rõ, KHÔNG "*"
c.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
c.setAllowedHeaders(List.of("Authorization", "Content-Type"));
c.setAllowCredentials(true);
var s = new UrlBasedCorsConfigurationSource();
s.registerCorsConfiguration("/**", c);
return s;
}
http.cors(Customizer.withDefaults()); // ❗ THIẾU dòng này thì bean trên bị BỎ QUA
// ❗ allowCredentials(true) + allowedOrigins("*") là tổ hợp BỊ CẤM theo chuẩn.
// Spring ném lỗi lúc khởi động. Dùng setAllowedOriginPatterns nếu thật sự cần mẫu.
// ❗ Preflight OPTIONS phải được permitAll, nếu không trình duyệt nhận 401 cho
// chính bước hỏi đường và bạn sẽ tưởng CORS hỏng.Request vẫn chạy tới server và vẫn được xử lý — kể cả thao tác ghi; trình duyệt chỉ chặn phần đọc kết quả sau đó.
Thiếu
http.cors(...)trong chuỗi filter thì cái beanCorsConfigurationSourcecủa bạn bị bỏ qua hoàn toàn.allowCredentials(true)đi cùngallowedOrigins("*")là tổ hợp bị chuẩn cấm — Spring ném lỗi ngay lúc khởi động.Preflight
OPTIONSphải đượcpermitAll, nếu không trình duyệt nhận 401 cho chính bước hỏi đường.
Checklist trước khi bấm deploy
Phần này là thứ đáng in ra dán lên màn hình. Ba mục đầu là những chỗ hay quên nhất, và mục thứ ba — rate limiting — thì Spring Security không có sẵn, nên nếu bạn không chủ động thêm thì nó đơn giản là không tồn tại.
// CHECKLIST TRƯỚC KHI LÊN PRODUCTION — mười ngày bảo mật gói lại.
// ① SECRET — không bao giờ nằm trong repo. Đây là lỗi rò rỉ phổ biến nhất thế giới.
jwt.secret: ${JWT_SECRET} // ✅ từ biến môi trường / vault
jwt.secret: my-super-secret-key // ❌ và nó nằm trong git history VĨNH VIỄN
// ❗ Lỡ commit rồi thì ĐỔI SECRET, đừng chỉ xóa dòng đó — lịch sử git vẫn còn.
// ❗ Bot quét GitHub tìm secret trong vài PHÚT sau khi push. Không phải vài ngày.
// ② HTTPS — bắt buộc, và nói cho trình duyệt biết:
server.ssl.enabled: true
http.headers(h -> h.httpStrictTransportSecurity(s -> s.maxAgeInSeconds(31536000)));
// ❗ Không có HTTPS thì mọi thứ mười ngày qua đều vô nghĩa: token đi trong bản rõ.
// ③ RATE LIMITING — thứ Spring Security KHÔNG có sẵn, và hay bị quên nhất.
// Không có nó, kẻ tấn công thử một triệu mật khẩu vào /login mà không ai cản.
Bucket bucket = Bucket.builder() // thư viện Bucket4j
.addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofMinutes(1))))
.build();
if (!bucket.tryConsume(1)) return status(429).build(); // 429 Too Many Requests
// ❗ Giới hạn theo IP + theo TÀI KHOẢN. Chỉ theo IP thì một mạng công ty
// dùng chung NAT sẽ bị chặn oan cả văn phòng.
// ④ THÔNG BÁO LỖI — đừng nói cho kẻ tấn công biết hắn đoán đúng cái gì:
"Email không tồn tại" // ❌ giúp hắn liệt kê tài khoản có thật
"Sai email hoặc mật khẩu" // ✅ một câu cho cả hai trường hợp
// ⑤ NHỮNG THỨ ĐÃ HỌC, đừng để rơi:
// · @JsonIgnore hoặc DTO riêng cho field nhạy cảm (Ngày 47)
// · Không log token, mật khẩu, số thẻ (Ngày 48)
// · Quyền đặt ở tầng service, nhớ @EnableMethodSecurity (Ngày 47)
// · Kiểm iss của token, đừng tin mọi auth server (Ngày 49)
// · Access token ngắn hạn + refresh token thu hồi được (Ngày 46)
// · Dùng BCrypt/Argon2 cho mật khẩu — KHÔNG BAO GIỜ tự nghĩ ra cách bămSecret lỡ commit thì phải đổi secret, không phải chỉ xóa dòng đó — lịch sử git vẫn còn, và bot quét GitHub tìm ra trong vài phút.
Không có HTTPS thì mười ngày vừa rồi vô nghĩa: token đi trong bản rõ, ai nghe được đường truyền là có tất cả.
Rate limiting giới hạn theo cả IP lẫn tài khoản — chỉ theo IP thì một văn phòng dùng chung NAT bị chặn oan.
Thông báo lỗi đăng nhập phải mơ hồ có chủ đích: "sai email hoặc mật khẩu", đừng nói "email không tồn tại" cho kẻ tấn công liệt kê tài khoản.
Bài tập nhỏ
Bật lại CSRF trên một ứng dụng Thymeleaf và xem form thiếu token bị 403 thế nào.
Gọi API của bạn bằng
curltừ một "origin" bất kỳ để tự thấy CORS không cản gì cả.Xóa dòng
http.cors(...)và xem bean CORS bị bỏ qua ra sao.Thêm Bucket4j giới hạn 5 lần đăng nhập mỗi phút rồi thử sai mật khẩu 10 lần.
Chạy
git log -ptìm xem trong lịch sử repo của bạn có secret nào không — thường là có.
Kết lại
Nửa chặng đường, và mười ngày bảo mật khép lại bằng bốn ý: CSRF phụ thuộc đúng một câu hỏi — trình duyệt có tự gửi thông tin đăng nhập không — nên tắt nó trên ứng dụng dùng session cookie là một lỗ hổng thật; CORS bảo vệ người dùng chứ không bảo vệ server, và request vẫn chạy tới server dù trình duyệt có chặn phần đọc kết quả; secret lỡ commit thì phải đổi, vì lịch sử git giữ vĩnh viễn còn bot thì quét trong vài phút; và rate limiting là thứ Spring Security không có sẵn nên không ai thêm thì nó không tồn tại. Ngày 51 mở giai đoạn mới, nhẹ nhàng hơn hẳn: unit test tầng service với JUnit 5 và Mockito. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


