Spring Security & Testing/CORS — Same-Origin Policy, preflight, và Spring config
16/20
Bài 16 / 20~14 phútAuthorization & Web SecurityMiễn phí lượt xem

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:

URLOrigin
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:

  1. 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).
  2. 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.

Vòng bốn góc: JS ở olhub.org gọi fetch tới api.olhub.org, mũi tên gửi đi trọn vẹn nên server xử lý request và side-effect đã xảy ra, response 200 kèm body đi ngược về browser, nhưng browser chặn ở bước cuối vì thiếu Access-Control-Allow-Origin khớp origin nên JS nhận TypeError thay vì đọc được body

1.3 Same-Origin vs Same-Site — đừng nhầm

Khái niệmĐịnh nghĩaVí dụ cùng
Same-Originscheme + host + port đều khớphttps://olhub.org vs https://olhub.org
Same-SiteeTLD+1 (registrable domain) khớphttps://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.orgapi.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ặc POST.
  • Header chỉ thuộc safe list: Accept, Accept-Language, Content-Language, Content-Type (giới hạn application/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

Trục thời gian năm bước: SPA gọi một lời fetch POST với body JSON và header Authorization, browser tự chèn một request OPTIONS mang Access-Control-Request-Method POST, server trả 200 kèm Allow-Origin, Allow-Methods, Allow-Headers, Max-Age 3600 và Vary Origin, preflight pass được cache một giờ, rồi POST thật mới chạy và trả 201 Created

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.

Cache preflight per-resource

Max-Age cache per URL, không global. OPTIONS /api/projectsOPTIONS /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 allowedMethodsallowedHeaders

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.

"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:

Chuỗi năm bước: user đã login olhub.org nên cookie session nằm trong browser, user mở evil.com và JS ở đó gọi fetch tới api.olhub.org/user/profile với credentials include, browser tự đính kèm cookie olhub.org nên request tới nơi và server xử lý, server trả Access-Control-Allow-Origin sao kèm Access-Control-Allow-Credentials true, và browser từ chối bằng network error TypeError Failed to fetch — nếu browser không chặn ở bước này thì mọi website đều đọc được profile của mọi user đang đăng nhập

Đọ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 credentials flag is set and Access-Control-Allow-Origin is *, 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ứngNguyên nhânFix
No 'Access-Control-Allow-Origin' headerOrigin chưa whitelistThêm vào allowedOrigins
Wildcard * + credentials errorSpec violationĐổi sang exact origins hoặc allowedOriginPatterns
Method PUT is not allowedPUT thiếu trong allowedMethodsThêm vào list
Header X-Trace-Id not allowedHeader custom thiếuThêm vào allowedHeaders
OPTIONS trả 401Auth filter chặn preflightDùng http.cors(...)CorsFilter đăng ký trước auth
Location header null trong JSHeader custom chưa exposeThêm vào setExposedHeaders
CORS ổn ở dev, fail productionOrigins khác per-envConfig per-environment (YAML profile)
CORS fail bí ẩn qua CDNCDN không giữ Vary: OriginConfig 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: CorsFilter là một trong 15+ filter của Spring Security chain. Bài đó giải thích thứ tự filter, vì sao CorsFilter phải đứng trước AuthorizationFilter, và cách dùng http.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, Authorization header). Browser hỏi server trước khi gửi request thật; kết quả cache theo Max-Age per-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ới TypeError: Failed to fetch.
  • Fix wildcard + credentials: dùng exact origins list, allowedOriginPatterns cho subdomain động, hoặc wildcard chỉ khi credentials = false.
  • exposedHeaders cần khai báo rõ cho header custom muốn JS đọc được.
  • Vary: Origin trong response cần thiết khi response đi qua CDN/cache.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Frontend SPA tại https://olhub.org gọi API tại https://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ì?
  2. Q2
    Giải thích khi nào browser gửi preflight OPTIONS và khi nào không. Một request POST /api/projects với Content-Type: application/jsonAuthorization: Bearer eyJ... có preflight không? Tại sao spec thiết kế preflight?
  3. Q3
    Tạ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.
  4. Q4
    Bạ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.
  5. Q5
    setExposedHeaders khác setAllowedHeaders ở điểm gì? Cho ví dụ tình huống cần dùng setExposedHeaders và 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

Đặt 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

Bài tiếp theo

CSRF — Cross-Site Request Forgery và cách phòng thủ