99 Ngày Spring — Ngày 20: CORS
Sớm muộn mọi backend cũng nhận được câu hỏi từ frontend: "API bị chặn CORS". Hiểu đúng hiện tượng này cần tách hai khái niệm: same-origin policy — luật an toàn mặc định của trình duyệt, và CORS — cơ chế để server nới lỏng có kiểm soát luật đó. Hôm nay ta cấu hình CORS cho đúng: hiểu preflight, chọn giữa @CrossOrigin và cấu hình global, cùng các quy tắc an toàn cho production — khép lại giai đoạn REST API.

Same-origin policy — luật của trình duyệt
// ORIGIN = scheme + host + port — khác BẤT KỲ phần nào là khác origin:
// https://motdev.vn vs https://api.motdev.vn → khác (host)
// https://motdev.vn vs http://motdev.vn → khác (scheme)
// http://localhost:3000 vs http://localhost:8080 → khác (port)!
// SAME-ORIGIN POLICY: trình duyệt chặn JavaScript ĐỌC response
// từ origin khác — cơ chế an toàn mặc định của web.
// Tình huống kinh điển khi dev:
// Frontend http://localhost:3000 (React/Vue dev server)
// Backend http://localhost:8080 (Spring Boot)
// → fetch() bị chặn: "blocked by CORS policy"
// Ba điều thường bị hiểu nhầm:
// 1. Server VẪN nhận và xử lý request — trình duyệt chỉ chặn việc ĐỌC response
// 2. CORS là cơ chế NỚI LỎNG có kiểm soát, không phải tường lửa của server
// 3. curl/Postman/server-to-server không bị ảnh hưởng — đây là luật CỦA TRÌNH DUYỆTĐịnh nghĩa origin chính xác đến từng phần: scheme, host, và port — vì vậy
localhost:3000gọilocalhost:8080đã là cross-origin, tình huống mọi dev gặp ngay tuần đầu.Ba hiểu nhầm trong code comment trên đáng đọc kỹ — đặc biệt điều 1: request vẫn tới server và được xử lý; một thao tác ghi dữ liệu có thể đã thành công dù frontend báo lỗi CORS.
Hệ quả của điều 3: CORS không phải lớp bảo mật của API — kẻ tấn công gọi thẳng bằng script không qua trình duyệt; kiểm soát truy cập thật sự là việc của authentication (Giai đoạn 5).
Preflight — request thăm dò OPTIONS
// Với request "không đơn giản" (PUT/DELETE, header tùy chỉnh, JSON...),
// trình duyệt gửi TRƯỚC một request thăm dò — PREFLIGHT:
OPTIONS /api/posts HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
// Server đồng ý → trả các header cho phép:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600 // cache kết quả preflight 1 giờ
// Sau đó request THẬT mới được gửi.
// Thiếu cấu hình CORS = server không trả các header trên
// → trình duyệt hủy ngay từ bước preflightTrình duyệt phân loại request: loại đơn giản (GET/HEAD/POST với content type của form) đi thẳng; còn lại — gồm mọi request JSON — phải qua preflight OPTIONS xin phép trước.
Đây là lý do trong tab Network thường thấy hai request cho một lần gọi: OPTIONS rồi mới đến POST thật — và lý do lỗi CORS xuất hiện trước cả khi request thật được gửi.
Access-Control-Max-Agecho phép trình duyệt cache kết quả preflight — giảm số request OPTIONS lặp lại cho cùng endpoint.
Cấu hình: @CrossOrigin và global
// Cách 1 — @CrossOrigin: nhanh, phạm vi hẹp (một controller/method):@CrossOrigin(origins = "http://localhost:3000")@RestController@RequestMapping("/api/posts")public class PostController { ... }
// Cách 2 — cấu hình GLOBAL: một nơi duy nhất cho toàn bộ API (khuyến nghị):@Configurationpublic class CorsConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins}") // đọc từ config theo môi trường
private String[] allowedOrigins; // (profiles — Ngày 09)
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAge(3600);
}
}
# application-dev.yml: app.cors.allowed-origins: http://localhost:3000
# application-prod.yml: app.cors.allowed-origins: https://motdev.vn@CrossOriginphù hợp cho thử nghiệm nhanh hoặc ngoại lệ cục bộ; API thực tế nên dùng cấu hình global — một nơi duy nhất, không bỏ sót controller nào, cùng triết lý tập trung hóa của bài xử lý lỗi (Ngày 16).Điểm đáng giá nhất của mẫu trên: origin đọc từ cấu hình theo profile (Ngày 08–09) — dev/staging/production mỗi môi trường một danh sách, không sửa code khi triển khai.
allowCredentials(true)chỉ bật khi frontend thật sự gửi cookie/authorization header kèm request — và khi đó origin bắt buộc đích danh, không được là*.
Quy tắc an toàn cho production
// Các quy tắc giữ CORS đúng và an toàn:
// 1. KHÔNG mở "*" ở production — liệt kê đích danh origin được phép.// Đặc biệt: allowedOrigins("*") + allowCredentials(true) là tổ hợp// BỊ CẤM — Spring ném lỗi ngay khi khởi động.
// 2. Origin theo môi trường qua profile (Ngày 09) —// dev mở localhost, production chỉ mở domain thật.
// 3. Đọc lỗi CORS ở ĐÚNG chỗ: trình duyệt (DevTools → Console/Network).// Log server thường không có gì bất thường — request vẫn được xử lý,// chỉ response bị trình duyệt từ chối giao cho JavaScript.
// 4. Có Spring Security (Giai đoạn 5): cấu hình CORS phải được// khai báo với filter chain — http.cors(...) — vì security filter// chạy TRƯỚC MVC; thiếu nó, preflight bị chặn từ tầng security.Quy tắc 3 tiết kiệm nhiều giờ debug nhất: lỗi CORS là hiện tượng phía trình duyệt — nơi chẩn đoán là DevTools (response thiếu header
Access-Control-Allow-Originnào), không phải log server.Quy tắc 4 là cái bẫy phổ biến khi sang Giai đoạn 5: thêm Spring Security xong CORS "tự nhiên" hỏng — vì security filter chặn preflight trước khi cấu hình MVC kịp can thiệp; ghi nhớ để không bất ngờ.
Bài tập nhỏ
Tạo một trang HTML chạy ở
localhost:3000(hoặcpython -m http.server 3000) gọifetchtới APIlocalhost:8080— quan sát lỗi CORS trong Console khi chưa cấu hình.Thêm cấu hình global cho phép
http://localhost:3000— xác nhận request thành công và soi cặp OPTIONS + POST trong tab Network.Gửi một POST với
Content-Type: application/jsonvà một GET đơn giản — so sánh: request nào sinh preflight, request nào không.Thử tổ hợp
allowedOrigins("*")+allowCredentials(true)— đọc thông báo lỗi Spring ném ra khi khởi động và giải thích vì sao tổ hợp này bị cấm.
Kết luận
CORS giờ nằm đúng vị trí trong bức tranh: same-origin policy là luật của trình duyệt, preflight là bước xin phép, cấu hình global theo profile là cách triển khai đúng — và CORS không thay thế được kiểm soát truy cập thật sự. Giai đoạn 2 khép lại: API của ta nhận request chuẩn, trả response chuẩn, lỗi thống nhất, phân trang, xử lý file và phục vụ frontend cross-origin. Ngày 21 mở Giai đoạn 3 — Spring Data JPA: bắt đầu từ bức tranh tổng quan JPA và Hibernate — ORM là gì, và dữ liệu sẽ đi từ đối tượng Java xuống PostgreSQL như thế nào. 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.


