CORS & CSRF
Tung Bui Thanh (TungDaDev)11 mins read

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%:
- Giao thức (protocol/scheme):
httpvshttps→ khác nguồn. - Tên miền (host/domain):
company.comvsapi.company.com→ khác nguồn. - Cổng (port):
localhost:3000vslocalhost:8080→ khác nguồn.
# sao sop lại quan trọng?
Thử tưởng tượng không có SOP:
- 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. - Bạn mở thêm một tab, vào trang độc hại
evil-site.com. - JavaScript trên
evil-site.comgọifetch('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-Typekhácapplication/x-www-form-urlencoded,multipart/form-data,text/plain. (Để ý: request nào dùngapplication/jsoncũ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:
OPTIONSround-trip.POST/PUTpayload 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ênPUT /orders/123vàPUT /orders/124là 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: trueTrì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ớiOrigin: https://attacker.com. - Server của bạn ngây thơ đọc header rồi trả về
Access-Control-Allow-Origin: https://attacker.comkèmcredentials: 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ất | Cơ chế cho frontend ĐỌC KẾT QUẢ (read access) từ domain khác | Kẻ 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ửi | Gử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 đúng | Dù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: 86400Nế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ùngAccess-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àoAllow-Originmà không check qua whitelist. - 3. Cache preflight (
max-age): ĐặtAccess-Control-Max-Agevà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
OPTIONSphả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=LaxhoặcSameSite=Strictcho mọi session cookie, đồng thời kiểm tra headerOrigin/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ênblog.company.comvàapi.company.comvẫ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 😎 👍🏻 🚀 🔥.