Backend

99 Ngày Spring — Ngày 20: CORS

SSite Admin
16 tháng 08, 2026 6 phút đọc 16 lượt xem
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.

Sketchnote Ngày 20: CORS — same-origin policy, preflight request, cấu hình global theo profile và quy tắc an toàn

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:3000 gọi localhost: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 preflight
  • Trì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-Age cho 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
  • @CrossOrigin phù 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-Origin nà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ặc python -m http.server 3000) gọi fetch tới API localhost: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/json và 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!

S

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.

Bài viết liên quan

99 Ngày Java — Ngày 21: StringBuilder & xử lý chuỗi nâng cao

Ngày 21 của 99 Ngày Java: vì sao nối chuỗi bằng + trong vòng lặp có chi phí bình phương, StringBuilder với capacity và bộ API thường dùng, String pool — lý do == đôi khi tình cờ đúng nhưng equals mới là quy tắc, cùng String.format và text block cho chuỗi nhiều dòng dễ đọc.

17 thg 8, 20266 phút2
99 Ngày Spring — Ngày 21: JPA & Hibernate tổng quan

Ngày 21 của 99 Ngày Spring, mở đầu giai đoạn Spring Data JPA: impedance mismatch giữa đối tượng và bảng quan hệ, phân vai JPA (đặc tả) — Hibernate (cài đặt) — Spring Data JPA (tầng tiện ích), EntityManager với persistence context, dirty checking, first-level cache, và lựa chọn ddl-auto đúng cho từng môi trường.

17 thg 8, 20267 phút4
99 Ngày Java — Ngày 20: Record & lớp bất biến

Ngày 20 của 99 Ngày Java: record — lớp dữ liệu trong một dòng khai báo với equals/hashCode/toString tự sinh, compact constructor cho validation, so sánh với Lombok và JavaBean, value semantics cùng record pattern của Java 21 — và ranh giới sử dụng: DTO dùng record, entity dùng class.

16 thg 8, 20266 phút12