CORS — Same-Origin Policy, preflight, và Spring config
CORS (Cross-Origin Resource Sharing) không phải tấn công — đây là cơ chế browser tự vệ chặn JavaScript đọc response cross-origin. Bài này bóc origin tuple theo RFC 6454, khi nào browser kích hoạt preflight OPTIONS, cách Spring config allowedOrigins/Methods/Headers/Credentials, và vì sao spec cấm allowedOrigins(*) kết hợp allowCredentials(true).
TL;DR: CORS (Cross-Origin Resource Sharing, Fetch Standard) là policy browser áp lên JavaScript: nếu script ở origin A gọi API ở origin B (khác scheme, host, hoặc port), browser chặn JS đọc response trừ khi server B opt-in qua Access-Control-Allow-Origin header. Với "non-simple request" (JSON body, Authorization header), browser gửi preflight OPTIONS trước để hỏi server có chấp nhận không. Spring Security config CORS qua CorsConfigurationSource + http.cors(...) — CorsFilter đăng ký sớm trong chain, trước AuthorizationFilter, để preflight không bị chặn bởi auth. Spec cấm tổ hợp allowedOrigins("*") + allowCredentials(true) vì tổ hợp đó cho phép mọi website attacker gọi API kèm cookie/token rồi đọc response — browser reject ngay.
1. Same-Origin Policy và origin tuple
1.1 Origin là gì theo RFC 6454
RFC 6454 — The Web Origin Concept định nghĩa origin là tuple ba thành phần (scheme, host, port). Hai URL chỉ cùng origin khi cả ba khớp chính xác:
| URL | Origin |
|---|---|
https://olhub.org/courses | (https, olhub.org, 443) |
https://olhub.org:8080/courses | (https, olhub.org, 8080) |
https://api.olhub.org/courses | (https, api.olhub.org, 443) |
http://olhub.org/courses | (http, olhub.org, 80) |
SPA tại https://olhub.org gọi API tại https://api.olhub.org — host khác — là cross-origin. Browser mặc định chặn JavaScript đọc response.
1.2 Vì sao Same-Origin Policy tồn tại
SOP giải quyết một vấn đề cụ thể: ngăn malicious site ăn cắp dữ liệu user đang đăng nhập.
Kịch bản không có SOP: bạn login bank.com xong mở tab mới vào evil.com. JS tại evil.com gọi fetch("https://bank.com/transactions") — browser tự đính kèm cookie bank.com (vì cookie theo domain, không theo origin của script). Không có SOP, evil.com đọc được số tài khoản, lịch sử giao dịch.
SOP can thiệp ở chiều đọc response, không phải chiều gửi request:
- Request vẫn đến server (lý do lịch sử — HTML form cross-site đã tồn tại từ trước khi JS xuất hiện).
- JS không được đọc response body, trừ khi server opt-in qua CORS header.
Phân biệt này quan trọng: SOP chặn đọc, không chặn gửi. Chính vì thế CSRF vẫn nguy hiểm ngay cả khi có SOP. Request POST transfer tiền đã đến server — attacker không cần đọc response để đạt mục đích. CORS và CSRF giải quyết hai chiều khác nhau của cùng mối đe dọa cross-site.

1.3 Same-Origin vs Same-Site — đừng nhầm
| Khái niệm | Định nghĩa | Ví dụ cùng |
|---|---|---|
| Same-Origin | scheme + host + port đều khớp | https://olhub.org vs https://olhub.org |
| Same-Site | eTLD+1 (registrable domain) khớp | https://app.olhub.org vs https://api.olhub.org |
Same-site lỏng hơn một bậc so với same-origin: app.olhub.org và api.olhub.org là same-site (cùng olhub.org) nhưng cross-origin (host khác). Khái niệm same-site được dùng trong SameSite cookie attribute — cơ sở cho phòng thủ CSRF hiện đại, bóc ở bài tiếp theo: CSRF & khi nào tắt.
2. Preflight OPTIONS — khi nào và tại sao
2.1 Simple request vs non-simple request
Browser chia request thành hai loại. Simple request (không cần preflight) phải thỏa đồng thời:
- Method là
GET,HEAD, hoặcPOST. - Header chỉ thuộc safe list:
Accept,Accept-Language,Content-Language,Content-Type(giới hạnapplication/x-www-form-urlencoded,multipart/form-data,text/plain).
Mọi REST API hiện đại đều gửi Content-Type: application/json hoặc kèm Authorization: Bearer ... — cả hai đều khiến request là non-simple. Mọi POST/PUT/DELETE với JSON body đều preflight.
Lý do spec thiết kế preflight: với simple request (form HTML cổ điển), browser cứ gửi vì đó là hành vi web đã tồn tại từ những năm 1990. Với request mới (custom header, JSON body), browser hỏi trước để server có cơ hội từ chối trước khi side-effect xảy ra.
2.2 Flow preflight

Access-Control-Max-Age (giây) cho browser cache kết quả preflight cho cùng URL + method + header combination. Chrome cap 7200 giây, Firefox cap 86400. Đặt 3600 là an toàn cross-browser.
Header Vary: Origin rất quan trọng nếu response đi qua CDN: báo cache "key bắt buộc bao gồm cả giá trị Origin header". Thiếu Vary: Origin, CDN có thể trả response chứa Access-Control-Allow-Origin: https://olhub.org cho request từ https://evil.com — CORS fail bí ẩn, khó debug.
Max-Age cache per URL, không global. OPTIONS /api/projects và OPTIONS /api/projects/42 là hai cache entry độc lập. App nhiều endpoint sẽ thấy preflight bùng phát ở lần warm cache đầu — đây là bình thường, không phải lỗi.
3. Spring CORS config
3.1 Tích hợp Spring Security (pattern đúng)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // stateless JWT API
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://olhub.org", "https://app.olhub.org"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
config.setExposedHeaders(List.of("X-Total-Count", "Location", "X-Request-Id"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Khi gọi http.cors(...), Spring Security đăng ký CorsFilter vào vị trí rất sớm trong chain — trước AuthorizationFilter. Preflight OPTIONS được trả ngay tại CorsFilter, không bao giờ chạm tới auth logic. Đây là điểm khác biệt quan trọng so với cách config tiếp theo.
Tham khảo thêm cấu trúc SecurityFilterChain DSL và thứ tự filter tại SecurityFilterChain DSL.
3.2 Global qua WebMvcConfigurer — hạn chế khi dùng với Spring Security
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://olhub.org")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Hạn chế chính: cách này không đăng ký CorsFilter vào Spring Security filter chain. Preflight OPTIONS sẽ bị AuthorizationFilter chặn trả 401 trước khi CORS config được áp. Kết quả là frontend thấy CORS error trong khi thực tế là auth error.
Cách an toàn: luôn dùng http.cors(cors -> cors.configurationSource(...)) khi app có Spring Security.
3.3 Per-controller @CrossOrigin
@RestController
@RequestMapping("/api/widget")
@CrossOrigin(
origins = "https://embed.olhub.org",
methods = {RequestMethod.GET},
maxAge = 3600
)
public class WidgetController { /* ... */ }
Override config global cho riêng controller. Phù hợp cho public widget hoặc endpoint embed cross-domain với rule khác phần còn lại của API.
4. Các thuộc tính CORS — bóc từng cái
4.1 allowedOrigins vs allowedOriginPatterns
// Exact list — recommend cho production single-tenant
config.setAllowedOrigins(List.of("https://olhub.org", "https://app.olhub.org"));
// Pattern — cho subdomain dong (multi-tenant SaaS), Spring 5.3+
config.setAllowedOriginPatterns(List.of("https://*.olhub.org"));
Khi dùng allowedOriginPatterns, Spring không trả * về browser. Thay vào đó nó match pattern với Origin gửi tới, rồi echo lại đúng origin đó — browser accept vì nhận exact origin, không phải wildcard.
4.2 allowedMethods và allowedHeaders
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*")); // hoặc liệt ke cu the
allowedMethods("*") chấp nhận được — rủi ro thấp vì Spring MVC routing vẫn enforce method whitelist bên dưới. allowedHeaders("*") tiện cho dev; production strict có thể liệt kê Authorization, Content-Type, các trace header.
4.3 exposedHeaders — hay bị quên
Mặc định JavaScript chỉ đọc được một số response header an toàn (Cache-Control, Content-Type, Content-Length...). Mọi header custom muốn JS đọc được đều phải khai báo:
config.setExposedHeaders(List.of("X-Total-Count", "Location", "X-Request-Id"));
Trường hợp hay gặp: POST /api/resources trả về Location: /api/resources/42 nhưng frontend gọi response.headers.get("Location") nhận null — vì Location chưa được expose.
4.4 allowCredentials — cookie cần, JWT header KHÔNG cần
"Credentials" trong CORS nghĩa là credential browser tự đính kèm: cookie (khi fetch với credentials: "include") hoặc HTTP Basic. JWT trong Authorization: Bearer ... header là explicit attach — JS tự set header trên từng request, browser không bao giờ tự gửi. Với CORS, Authorization chỉ là một header cần được phép qua allowedHeaders, không liên quan tới credentials flag.
Config đúng cho SPA dùng JWT header thuần (không cookie):
config.setAllowedOrigins(List.of("https://olhub.org")); // origin cu the
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(false); // khong cookie -> khong can credentials
Đặt allowCredentials(true) "cho chắc" là anti-pattern: nó mở thêm bề mặt (mọi cookie của origin đi kèm request cross-origin được phép) và khoá bạn khỏi wildcard origin. Chỉ bật khi app thật sự dùng cookie — session phụ trợ hoặc HttpOnly cookie JWT.
4.5 maxAge
config.setMaxAge(3600L); // Chrome cap 7200, Firefox cap 86400
Dev nên đặt maxAge(0) để mỗi lần thay CORS rule không phải clear browser cache; production đặt 3600.
5. Cơ chế bên dưới — vì sao spec cấm allowedOrigins("*") + allowCredentials(true)
Đây là rule quan trọng nhất, và có lý do cụ thể từ attack chain — không phải rule tùy tiện.
Attack chain, và chỗ nó gãy:

Đọc kỹ vị trí chỗ gãy: server vẫn trả đúng cặp header đó, không hề báo lỗi gì. Bên từ chối là browser. Tổ hợp * + credentials: true có nghĩa là "bất kỳ website nào cũng có thể gọi API này kèm cookie/auth của user và đọc response" — universal account takeover, không cần khai thác lỗ hổng nào khác. Vì thế spec đẩy việc chặn xuống client thay vì tin vào cấu hình server.
Fetch Standard (https://fetch.spec.whatwg.org/#cors-protocol) cấm rõ:
If
credentialsflag is set andAccess-Control-Allow-Originis*, return a network error.
Browser kiểm tra ở phía client — ngay cả khi server trả * + credentials, browser throw TypeError: Failed to fetch và không cho JS đọc response.
Fix — ba cách:
// 1. Exact list (recommend production)
config.setAllowedOrigins(List.of("https://olhub.org"));
config.setAllowCredentials(true); // OK voi exact origin
// 2. Pattern cho subdomain dong
config.setAllowedOriginPatterns(List.of("https://*.olhub.org"));
config.setAllowCredentials(true); // Spring echo exact origin, khong tra wildcard
// 3. Wildcard hop le — khi khong can credentials
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(false); // public read-only API, khong can auth
6. CORS troubleshooting
| Triệu chứng | Nguyên nhân | Fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header | Origin chưa whitelist | Thêm vào allowedOrigins |
Wildcard * + credentials error | Spec violation | Đổi sang exact origins hoặc allowedOriginPatterns |
Method PUT is not allowed | PUT thiếu trong allowedMethods | Thêm vào list |
Header X-Trace-Id not allowed | Header custom thiếu | Thêm vào allowedHeaders |
| OPTIONS trả 401 | Auth filter chặn preflight | Dùng http.cors(...) — CorsFilter đăng ký trước auth |
Location header null trong JS | Header custom chưa expose | Thêm vào setExposedHeaders |
| CORS ổn ở dev, fail production | Origins khác per-env | Config per-environment (YAML profile) |
| CORS fail bí ẩn qua CDN | CDN không giữ Vary: Origin | Config CDN forward Vary header |
Verify nhanh bằng curl (loại bỏ browser cache):
curl -i -X OPTIONS https://api.olhub.org/api/projects \
-H "Origin: https://olhub.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization,Content-Type"
# Expect trong response:
# HTTP/1.1 200 OK
# Access-Control-Allow-Origin: https://olhub.org
# Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
# Access-Control-Allow-Headers: Authorization,Content-Type
# Access-Control-Allow-Credentials: true
# Access-Control-Max-Age: 3600
# Vary: Origin
Liên hệ các bài khác
- SecurityFilterChain DSL:
CorsFilterlà một trong 15+ filter của Spring Security chain. Bài đó giải thích thứ tự filter, vì saoCorsFilterphải đứng trướcAuthorizationFilter, và cách dùnghttp.cors(...)để đăng ký đúng vị trí — thiếu hiểu biết này dẫn trực tiếp tới lỗi preflight 401. - CSRF & khi nào tắt: CORS và CSRF thường bị nhầm vì cùng liên quan cross-site. Bài này bóc tại sao CORS (chặn đọc response) và CSRF (forged request) là hai vấn đề độc lập — và vì sao stateless JWT API disable CSRF trong khi vẫn cần CORS.
Tóm tắt
- Origin =
(scheme, host, port)theo RFC 6454. Ba thành phần phải khớp chính xác mới cùng origin. - SOP tồn tại để chặn JS cross-origin đọc dữ liệu user. SOP chặn đọc response, không chặn gửi request — CSRF vẫn nguy hiểm vì thế.
- Preflight OPTIONS kích hoạt với non-simple request (JSON body,
Authorizationheader). Browser hỏi server trước khi gửi request thật; kết quả cache theoMax-Ageper-URL. - Spring config:
http.cors(cors -> cors.configurationSource(...))—CorsFilterđăng ký trước auth filter, preflight bypass authentication đúng cách. - Spec cấm
allowedOrigins("*")+allowCredentials(true): tổ hợp cho phép mọi website attacker đọc response kèm cookie/token user — universal account takeover. Browser reject ngay vớiTypeError: Failed to fetch. - Fix wildcard + credentials: dùng exact origins list,
allowedOriginPatternscho subdomain động, hoặc wildcard chỉ khicredentials = false. exposedHeaderscần khai báo rõ cho header custom muốn JS đọc được.Vary: Origintrong response cần thiết khi response đi qua CDN/cache.
Tự kiểm tra
- Q1Frontend SPA tại
https://olhub.orggọi API tạihttps://api.olhub.org. Browser hiện "blocked by CORS policy". Chỉ ra: (a) đây là cross-origin vì lý do gì theo RFC 6454, (b) tại sao browser chặn, (c) bước đầu tiên để fix là gì? - Q2Giải thích khi nào browser gửi preflight OPTIONS và khi nào không. Một request
POST /api/projectsvớiContent-Type: application/jsonvàAuthorization: Bearer eyJ...có preflight không? Tại sao spec thiết kế preflight? - Q3Tại sao spec Fetch Standard cấm tổ hợp
allowedOrigins("*")+allowCredentials(true)? Mô tả attack chain cụ thể nếu tổ hợp này được phép, và liệt kê ba cách fix. - Q4Bạn config CORS qua
WebMvcConfigurer.addCorsMappings()nhưng preflight OPTIONS vẫn trả 401. Giải thích cơ chế tại sao, và cách fix đúng với Spring Security. - Q5
setExposedHeaderskhácsetAllowedHeadersở điểm gì? Cho ví dụ tình huống cần dùngsetExposedHeadersvà hậu quả nếu quên.
Bài tiếp theo: CSRF & khi nào tắt
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên