Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

CORS & CSRF

Tung Bui Thanh (TungDaDev)11 mins read

Cors.webp

Một hiểu lầm rất hay gặp là nghĩ CORS (Cross-Origin Resource Sharing) là bức tường lửa bảo vệ server khỏi bị tấn công từ bên ngoài. Thực tế ngược lại: CORS là cơ chế do trình duyệt thực thi để NỚI LỎNG Same-Origin Policy (SOP). cURL, Postman, mobile app hay script của hacker đều chẳng quan tâm CORS là gì. Coi CORS là lớp xác thực (authentication) cho API là sai lầm rất nguy hiểm về bảo mật.

Kiến trúc bây giờ thường thế này: frontend (SPA viết bằng React/Vue/Next.js) nằm ở một domain (https://app.company.com), còn backend REST/GraphQL API nằm ở domain hoặc port khác (https://api.company.com). Và lỗi No 'Access-Control-Allow-Origin' header is present on the requested resource là bức tường đầu tiên mà dev nào cũng từng đâm vào.

Có điều, thay vì tìm hiểu cho kỹ, nhiều team chọn cách nguy hiểm: set Access-Control-Allow-Origin: * hoặc phản chiếu lại (reflect) bất kỳ Origin nào gửi lên. Dưới đây mình đi lại từ gốc cơ chế bảo mật của trình duyệt, rồi tới cách siết CORS cho chặt (hardening) ở tầng API Gateway.


# nền tảng: same-origin policy (sop) & cái khó của trình duyệt

Trình duyệt nào bây giờ (Chrome, Safari, Firefox) cũng tuân theo một luật bảo mật cơ bản nhất: Same-Origin Policy (SOP), do Netscape đưa ra từ năm 1995.

Hai URL được coi là cùng nguồn (same-origin) khi và chỉ khi cả 3 thứ sau khớp nhau 100%:

  1. Giao thức (protocol/scheme): http vs https → khác nguồn.
  2. Tên miền (host/domain): company.com vs api.company.com → khác nguồn.
  3. Cổng (port): localhost:3000 vs localhost:8080 → khác nguồn.

# sao sop lại quan trọng?

Thử tưởng tượng không có SOP:

  1. Bạn đăng nhập trang ngân hàng my-bank.com, ngân hàng lưu session cookie vào trình duyệt.
  2. Bạn mở thêm một tab, vào trang độc hại evil-site.com.
  3. JavaScript trên evil-site.com gọi fetch('https://my-bank.com/api/account'), trình duyệt tự gắn cookie đăng nhập của bạn vào, và script đọc được trọn response: số dư, lịch sử giao dịch, thậm chí cả CSRF token trong trang. Có token rồi thì lệnh chuyển tiền giả mạo cũng qua nốt.

Để ý một chi tiết hay bị hiểu nhầm: SOP chủ yếu chặn việc đọc response, chứ không chặn việc gửi request. Ngay cả khi có SOP, một form ẩn trên evil-site.com vẫn tự submit sang my-bank.com được, và trình duyệt vẫn có thể gắn cookie vào. Đó chính là tấn công CSRF, và phải phòng bằng cơ chế khác (xem bảng CORS vs CSRF bên dưới).

CORS sinh ra cho một nhu cầu chính đáng: cho phép my-bank-frontend.com truy cập tài nguyên của my-bank-api.com một cách hợp lệ, khi chính server đồng ý rõ ràng.


# preflight request (HTTP options) là gì

Không phải request cross-origin nào cũng được gửi thẳng tới backend. Trình duyệt chia request làm 2 nhóm: Simple Requests và Preflighted Requests.

# khi nào phải chạy preflight options

Request sẽ phải chạy preflight nếu dính bất kỳ điều kiện nào dưới đây:

  • Dùng HTTP method khác GET, HEAD, POST.
  • Dùng Content-Type khác application/x-www-form-urlencoded, multipart/form-data, text/plain. (Để ý: request nào dùng application/json cũng ĐỀU phải preflight!)
  • Có custom header bất kỳ (ví dụ Authorization, X-Trace-Id, X-Tenant-Id).

# cái giá về hiệu năng: tốn gấp đôi round-trip (2x rtt)

Mỗi lần frontend gọi một API POST/PUT JSON, trình duyệt phải đi 2 lượt round-trip liên tiếp:

  1. OPTIONS round-trip.
  2. POST/PUT payload round-trip. → Mỗi thao tác bị cộng thêm một RTT. Với người dùng ở xa server (RTT vài trăm mili-giây, mạng di động) thì con số này rất đáng kể.

Mẹo tối ưu: trả về header Access-Control-Max-Age để trình duyệt cache kết quả preflight. Có hai điều cần biết. Thứ nhất, trình duyệt tự giới hạn giá trị này: Firefox tối đa 24 giờ (86400), Chromium tối đa 2 giờ (7200); không gửi header thì mặc định chỉ 5 giây. Thứ hai, cache preflight tính theo từng URL cụ thể (kèm origin), nên PUT /orders/123 và PUT /orders/124 là hai mục cache khác nhau. Với REST API có id trong path, cache preflight giúp ít hơn bạn nghĩ. Nếu preflight thật sự là vấn đề, cách triệt để là đưa frontend và API về cùng origin (ví dụ app.company.com/api/... qua reverse proxy), khi đó không còn CORS nữa. Nguồn: MDN Access-Control-Max-Age.


# mấy lỗ hổng bảo mật khi cấu hình CORS sai

# bẫy wildcard * đi kèm credentials

Nhiều bạn thử cấu hình thế này:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Trình duyệt chặn ngay cấu hình này! Theo Fetch Standard (WHATWG), nếu server cho gửi kèm thông tin định danh (credentials: true, tức cookies, HTTP auth), thì Access-Control-Allow-Origin bắt buộc phải là một domain cụ thể, không được dùng dấu sao *.

# lỗ hổng dynamic origin reflection (phản chiếu mù quáng)

Để "lách" luật trên, nhiều backend dev viết đoạn code tai hại này:

// LỖ HỔNG AN NINH NGHIÊM TRỌNG:
String origin = request.getHeader("Origin");
response.setHeader("Access-Control-Allow-Origin", origin); // Phản chiếu bất kỳ ai gửi lên!
response.setHeader("Access-Control-Allow-Credentials", "true");
  • Kịch bản tấn công: Hacker dựng trang https://attacker.com. Người dùng vào trang này, script của hacker gửi request sang API của bạn với Origin: https://attacker.com.
  • Server của bạn ngây thơ đọc header rồi trả về Access-Control-Allow-Origin: https://attacker.com kèm credentials: true.
  • Trình duyệt để JavaScript của hacker đọc trọn response nhạy cảm (số dư tài khoản, email, token)!

Kiểm tra whitelist bằng regex tự viết cũng dễ hớ: regex kiểu .*tungdadev\.com$ sẽ cho qua cả https://eviltungdadev.com, còn startsWith("https://tungdadev.com") thì cho qua https://tungdadev.com.attacker.net. Đừng whitelist origin null (iframe sandbox hay file local đều gửi được Origin: null). Và khi server trả Access-Control-Allow-Origin khác nhau tuỳ origin, response phải có Vary: Origin để CDN/proxy không cache nhầm header của origin này cho origin khác. CorsConfiguration của Spring đã lo phần so khớp và Vary cho bạn.

# phân biệt cho rõ: CORS vs CSRF

Tiêu chíCORS (Cross-Origin Resource Sharing)CSRF (Cross-Site Request Forgery)
Bản chấtCơ chế cho frontend ĐỌC KẾT QUẢ (read access) từ domain khácKẻ tấn công lợi dụng cookie để THỰC HIỆN HÀNH ĐỘNG (write/state mutation)
Trình duyệt làm gìSimple request vẫn được gửi lên server, chỉ là không cho JS đọc response nếu thiếu header CORS; request cần preflight thì preflight hỏng là request thật không được gửiGửi request và server xử lý bình thường! (Hacker không cần đọc kết quả, chỉ cần tiền bị trừ)
Cách phòng thủCấu hình whitelist origin cho đúngDùng SameSite=Strict/Lax Cookies hoặc Anti-CSRF Tokens

# gom về một chỗ: CORS hardening tại API gateway

Trong microservices, rải annotation @CrossOrigin lên từng controller của từng service là ác mộng khi quản lý: dễ sót, khó audit, muốn sửa cũng không sửa một chỗ được.

Cách nên làm: Xử lý trọn preflight và CORS ở tầng API Gateway (Spring Cloud Gateway / Envoy / NGINX), trước khi request chạm tới các microservice nghiệp vụ bên trong.

# cấu hình Spring cloud gateway cho production

# Spring Cloud Gateway 4.3+ (Spring Cloud 2025.0+). Bản cũ hơn bỏ "server.webflux."
spring:
  cloud:
    gateway:
      server:
        webflux:
          globalcors:
            # Cho preflight tới cả những path không khớp route nào
            add-to-simple-url-handler-mapping: true
            cors-configurations:
              '[/**]':
                # Whitelist nghiêm ngặt các domain frontend tin cậy
                allowed-origin-patterns:
                  - 'https://tungdadev.com'
                  - 'https://*.tungdadev.com'
                  - 'http://localhost:[3000,5173]' # Chỉ nên có ở profile dev, đừng để lên production
                allowed-methods:
                  - GET
                  - POST
                  - PUT
                  - DELETE
                  - PATCH
                  - OPTIONS
                allowed-headers:
                  - Authorization
                  - Content-Type
                  - X-Requested-With
                  - X-Correlation-Id
                exposed-headers:
                  - Content-Disposition
                  - X-Total-Count
                allow-credentials: true
                # Cache preflight (trình duyệt sẽ tự giới hạn: Chromium 2 giờ, Firefox 24 giờ)
                max-age: 86400

Nếu các service phía sau cũng tự thêm header CORS, response tới trình duyệt sẽ có hai header Access-Control-Allow-Origin và trình duyệt từ chối luôn. Khi đã gom về gateway thì tắt CORS ở service, hoặc dùng filter DedupeResponseHeader của gateway.

# xử lý bằng Spring security 6+ trong một service đơn lẻ

Không có gateway thì cấu hình chặt trong SecurityFilterChain:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // Bật CSRF protection nếu dùng Session Cookies; tắt nếu dùng stateless JWT Authorization header
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                // Không bắt buộc: khi đã bật .cors(), CorsFilter trả lời preflight trước bước authorize
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .anyRequest().authenticated()
            )
            .build();
    }
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(List.of("https://*.tungdadev.com", "http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
        config.setAllowCredentials(true);
        config.setMaxAge(Duration.ofHours(24));
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

Một chỗ cần cân nhắc trong đoạn trên: setAllowCredentials(true) chỉ cần khi trình duyệt phải gửi cookie (hoặc HTTP auth) sang API. Nếu API xác thực bằng JWT trong header Authorization thì không cần credentials, và tắt CSRF là hợp lý. Còn nếu đúng là dùng session cookie, thì cặp "credentials bật, CSRF tắt" như trên là mở cửa cho CSRF, phải bật lại CSRF protection.


# checklist bảo mật khi vận hành (security architect's checklist)

  • 1. Không dùng wildcard với credentials: Không bao giờ để Access-Control-Allow-Origin: * đi cùng Access-Control-Allow-Credentials: true.
  • 2. Không phản chiếu mù quáng (no blind reflection): Đừng lấy thẳng request.getHeader("Origin") gán vào Allow-Origin mà không check qua whitelist.
  • 3. Cache preflight (max-age): Đặt Access-Control-Max-Age vài giờ (Chromium giới hạn 7.200 giây, Firefox 86.400 giây) để bớt latency do các lệnh OPTIONS lặp lại.
  • 4. Chặn preflight ở gateway: Request OPTIONS phải được API Gateway trả lời ngay (204 No Content), không chuyển sâu vào microservice nội bộ cho tốn worker thread.
  • 5. Phòng CSRF cho đủ: Nếu app xác thực bằng cookie, phải bật SameSite=Lax hoặc SameSite=Strict cho mọi session cookie, đồng thời kiểm tra header Origin / Referer ở các API làm thay đổi dữ liệu. Nhớ là SameSite tính theo "site" (tên miền đăng ký), nên blog.company.com và api.company.com vẫn là cùng site: một subdomain bị chiếm vẫn gửi được cookie kèm theo, lúc đó vẫn cần CSRF token.

# lời kết

Gói lại: CORS chỉ quyết định trang nào được đọc response của API trong trình duyệt, nó không thay cho authentication, cũng không chống được CSRF. Cấu hình một whitelist chặt ở một chỗ (gateway), không phản chiếu origin, và nếu dùng cookie thì lo CSRF riêng. Muốn đọc kỹ hơn thì trang CORS trên MDN và CSRF Prevention Cheat Sheet của OWASP là hai chỗ đáng đọc.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.