CORS & CSRF — config + best practices
CORS bảo vệ browser khỏi đọc cross-origin response, CSRF chống ride session-based auth. Bài này bóc same-origin policy theo RFC 6454, preflight OPTIONS và caching, allowedOrigins/Methods/Headers/Credentials, Synchronizer Token Pattern, Double-Submit Cookie, SameSite cookie, khi nào disable CSRF (REST API stateless), khi nào enable (browser session).
CORS và CSRF là 2 cơ chế browser security thường bị nhầm lẫn vì cùng dính tới "cross-site". Bản chất khác hẳn nhau:
- CORS (Cross-Origin Resource Sharing — RFC nháp W3C, kế thừa RFC 6454 Origin) là policy đọc response mà browser áp lên JavaScript khi origin của script khác origin của API.
- CSRF (Cross-Site Request Forgery — OWASP A5:2021) là kỹ thuật tấn công lợi dụng việc browser tự đính kèm cookie/auth credential khi gửi request cross-site.
Cấu hình sai → một là frontend không gọi được API (CORS lỗi), hai là backend bị tấn công (CSRF disable nhầm context). Bài này tách riêng, bóc cơ chế, gắn vào Spring Security 6 filter chain.
1. Same-Origin Policy + RFC 6454
1.1 Origin được định nghĩa thế nào
RFC 6454 (Web Origin Concept) định nghĩa origin là tuple (scheme, host, port). Hai URL chung origin khi và chỉ khi cả 3 thành phần khớp chính xác (port mặc định cũng phải khớp với port mặc định của scheme):
| URL | Origin (scheme, host, port) |
|---|---|
https://olhub.org/page | (https, olhub.org, 443) |
https://olhub.org:8080/page | (https, olhub.org, 8080) |
https://api.olhub.org | (https, api.olhub.org, 443) |
http://olhub.org | (http, olhub.org, 80) |
Frontend SPA tại https://olhub.org gọi API tại https://api.olhub.org → host khác → cross-origin. Browser mặc định chặn JavaScript đọc response.
Vì sao policy tồn tại: ngăn malicious site ăn cắp dữ liệu user. Giả sử bạn login bank.com rồi mở tab evil.com. Không có same-origin policy, JS của evil.com có thể fetch("https://bank.com/transactions") rồi đọc về số tài khoản, vì cookie bank.com được browser tự đính kèm. Same-origin policy làm 2 việc:
- Cho phép request gửi đi (vì lý do compatibility — form HTML cross-site đã tồn tại từ thời browser sơ khai).
- Chặn JS đọc response trừ khi server opt-in qua CORS header.
Phân biệt quan trọng: SOP không chặn request, chỉ chặn đọc response. Đó là lý do CSRF tồn tại — request side-effect (POST/PUT/DELETE) vẫn đến server, không cần đọc response thì attacker đã đạt mục đích.
1.2 Same-Origin vs Same-Site
Hai khái niệm dễ lẫn:
| Khái niệm | Định nghĩa | Ví dụ same |
|---|---|---|
| Same-Origin | scheme + host + port khớp | https://app.olhub.org vs https://app.olhub.org |
| Same-Site | eTLD+1 (registrable domain) khớp | https://app.olhub.org vs https://api.olhub.org |
Chuẩn SameSite cookie attribute (RFC 6265bis) dùng khái niệm same-site, lỏng hơn same-origin một bậc. Đây là nền tảng cho cơ chế phòng thủ CSRF hiện đại — sẽ bóc kỹ ở phần 6.4.
1.3 CORS — opt-in cross-origin
Server cho phép specific origin đọc response qua header trả về:
GET /api/projects HTTP/1.1
Host: api.olhub.org
Origin: https://olhub.org
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://olhub.org
Access-Control-Allow-Credentials: true
Vary: Origin
Browser kiểm Access-Control-Allow-Origin echo lại đúng origin gốc → cho JS đọc body. Nếu mismatch → reject, console hiển thị lỗi blocked by CORS policy, fetch promise reject với TypeError: Failed to fetch (không kèm response code).
Header Vary: Origin rất quan trọng nếu response được cache (CDN, browser cache): nói cho cache "key cache phải bao gồm cả Origin", tránh trường hợp một origin được phép nhận response chứa Allow-Origin của origin khác.
2. Preflight OPTIONS
2.1 Khi nào browser preflight
Với "non-simple request", browser gửi OPTIONS preflight trước khi gửi request thật. Định nghĩa simple request (không cần preflight) theo Fetch spec:
- Method là
GET,HEAD, hoặcPOST. - Header chỉ thuộc whitelist:
Accept,Accept-Language,Content-Language,Content-Type(giới hạn 3 giá trị bên dưới). Content-Typechỉ được làapplication/x-www-form-urlencoded,multipart/form-data, hoặctext/plain.- Không có
ReadableStreambody, không có event listener trênXMLHttpRequest.upload.
Mọi REST API hiện đại (gửi Content-Type: application/json, hoặc kèm Authorization: Bearer ...) đều không simple → mọi POST/PUT/DELETE đều preflight.
2.2 Flow preflight
sequenceDiagram
participant Browser
participant API
Note over Browser: SPA: fetch("/api/projects", {method:"POST", headers:{Authorization:"Bearer ..."}})
Browser->>API: OPTIONS /api/projects
Note over Browser, API: Origin: https://olhub.org<br/>Access-Control-Request-Method: POST<br/>Access-Control-Request-Headers: Authorization,Content-Type
API->>Browser: 200 OK
Note over Browser, API: Access-Control-Allow-Origin: https://olhub.org<br/>Access-Control-Allow-Methods: GET,POST,PUT,DELETE<br/>Access-Control-Allow-Headers: Authorization,Content-Type<br/>Access-Control-Max-Age: 3600<br/>Vary: Origin, Access-Control-Request-Method
Note over Browser: Preflight OK — cache 1h, gửi request thật
Browser->>API: POST /api/projects
API->>Browser: 201 CreatedAccess-Control-Max-Age (giây) cho browser cache kết quả preflight cho cùng URL + cùng method + cùng header. Spec cho phép tối đa 86400 (1 ngày) trên Firefox, 7200 (2h) trên Chromium. Để 3600 là an toàn.
2.3 Per-resource cache
Lưu ý max-age cache per resource (per URL), không global. Nghĩa là OPTIONS /api/projects và OPTIONS /api/projects/42 là 2 entry cache độc lập. App nhiều endpoint sẽ thấy preflight bùng phát ở lần warm cache đầu. Mitigation: trong dev có thể dùng wildcard route guard, prod thì để max-age tối đa và chấp nhận cost.
3. Spring CORS config
3.1 Global qua WebMvcConfigurer
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://olhub.org", "https://app.olhub.org")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("Authorization", "Content-Type", "X-Request-Id")
.exposedHeaders("X-Total-Count", "X-Request-Id")
.allowCredentials(true)
.maxAge(3600);
}
}
Cách này nhanh, áp lên mọi @RestController. Nhược điểm: không tích hợp với Spring Security filter chain — preflight sẽ bị AuthorizationFilter chặn nếu app bật authentication. Phần 5.1 sẽ bóc.
3.2 Tích hợp Spring Security (recommend)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... other config
;
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", "X-Request-Id", "Location"));
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ị trí rất sớm trong chain (trước AuthorizationFilter). Preflight OPTIONS được trả ngay tại filter này, bỏ qua auth — đây mới là pattern đúng cho ứng dụng 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. Dùng cho endpoint widget public hoặc embed cross-domain với rule khác phần còn lại của API.
4. CORS attributes — bóc từng cái
4.1 allowedOrigins vs allowedOriginPatterns
config.setAllowedOrigins(List.of("https://olhub.org")); // exact list
config.setAllowedOriginPatterns(List.of("https://*.olhub.org")); // pattern, Spring 5.3+
Tuyệt đối tránh setAllowedOrigins(List.of("*")) đi cùng allowCredentials = true:
- Browser reject hard ở response level — fetch fail kèm console error rõ ràng.
- Combo này nếu lỡ chấp nhận sẽ cho phép mọi website gửi request kèm cookie/Authorization và đọc response → toàn bộ session/JWT của user bị lộ.
Khi cần subdomain động (multi-tenant SaaS), dùng allowedOriginPatterns — Spring sẽ match Origin gửi tới rồi echo lại đúng origin, không trả wildcard.
4.2 allowedMethods
config.setAllowedMethods(List.of("*"));
// hoặc cụ thể:
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
* cho methods chấp nhận được vì rủi ro thấp — server vẫn tự enforce method whitelist qua Spring MVC routing.
4.3 allowedHeaders
config.setAllowedHeaders(List.of("*"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
Đa số app dùng *. Frontend cần gửi Authorization, Content-Type, header tracing tự thêm (X-Request-Id, X-Trace-Id) — liệt kê đủ nếu cấu hình strict.
4.4 exposedHeaders
Mặc định JS chỉ đọc được CORS-safelisted response header (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma). Mọi header custom muốn JS đọc đều phải khai báo:
config.setExposedHeaders(List.of("X-Total-Count", "Location", "X-Request-Id"));
Trường hợp hay quên: Location ở response của POST /resources → frontend muốn redirect dùng response.headers.get("Location") nhưng nhận null vì chưa expose.
4.5 allowCredentials
config.setAllowCredentials(true);
true cần khi:
- Browser tự đính kèm cookie (
fetch(url, { credentials: "include" })). - Sử dụng HTTP Basic auth (cũng dùng cookie/credential).
JWT trong header Authorization: Bearer ... về kỹ thuật không cần credentials = true (Authorization là explicit header, không auto-attach cross-site). Nhưng đa số dự án đặt true cho đồng nhất khi SPA gọi với credentials: "include" để cookie session phụ trợ vẫn đi cùng.
4.6 maxAge
config.setMaxAge(3600L);
Chrome cap 7200, Firefox cap 86400. Dev nên đặt thấp (vd 0) để mỗi lần thay rule không phải clear browser cache; prod đặt 3600 giảm preflight overhead.
5. CORS troubleshooting
5.1 Bảng triệu chứng
| Triệu chứng console | Nguyên nhân | Fix |
|---|---|---|
No 'Access-Control-Allow-Origin' header | Origin chưa được whitelist | Thêm vào allowedOrigins |
'Access-Control-Allow-Origin' wildcard '*' khi credentials include | * + allowCredentials=true | Đổi sang exact origins hoặc allowedOriginPatterns |
Method PUT is not allowed | Method không có trong whitelist preflight | Thêm vào allowedMethods |
Header Authorization not allowed | Header custom thiếu | Thêm vào allowedHeaders |
| OPTIONS trả 401 | AuthorizationFilter chặn preflight | Dùng http.cors(...) để CorsFilter chạy trước |
| OPTIONS trả 405 | Endpoint không cho phép OPTIONS | Đảm bảo dùng Spring Security filter, hoặc bật dispatchOptionsRequest |
5.2 Vì sao preflight bị 401
// SAI — chỉ dùng WebMvcConfigurer addCorsMappings(), không gọi http.cors()
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
// → preflight OPTIONS /api/projects vào AuthorizationFilter → không có credential → 401
Fix:
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
Khi gọi http.cors(...), Spring Security inject CorsFilter ngay sau WebAsyncManagerIntegrationFilter — trước mọi auth filter. Preflight trả 200 không qua auth.
5.3 Trace filter chain
Bật log để xác minh thứ tự filter:
logging.level.org.springframework.security: DEBUG
logging.level.org.springframework.web.cors: TRACE
Sẽ thấy log dạng:
Securing OPTIONS /api/projects
Invoking CorsFilter (1/15)
Handling CORS preflight, returning 200
Nếu thấy Invoking AuthorizationFilter xuất hiện trước CorsFilter → cấu hình sai.
6. CSRF — Cross-Site Request Forgery
6.1 Kịch bản tấn công kinh điển
1. User login bank.com → cookie session set (HttpOnly).
2. Cùng browser, user mở evil.com (phishing email, hoặc ad network compromise).
3. evil.com chứa form:
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker_account" />
<input name="amount" value="10000" />
</form>
<script>document.forms[0].submit()</script>
4. Browser submit POST tới bank.com KÈM cookie session (cookie tự attach cross-site).
5. bank.com đọc cookie → user đã login → process transfer.
6. User chỉ thấy tab redirect chớp nhoáng. Tiền đã bị chuyển.
CSRF lợi dụng việc browser tự đính kèm cookie với mọi request đến origin chứa cookie đó, kể cả request khởi phát từ origin khác. Same-origin policy chặn evil.com đọc response, nhưng request side-effect (transfer) đã hoàn thành.
6.2 Synchronizer Token Pattern (Spring default)
Server sinh CSRF token ngẫu nhiên, lưu vào session, đẩy ra form/meta tag. Mọi request side-effect phải kèm token. evil.com không cách nào đọc token (same-origin policy chặn cross-origin read), nên không forge được request hợp lệ.
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="abc123-token-from-session" />
<input name="to" value="..." />
<button>Transfer</button>
</form>
Server validate _csrf body khớp token trong session → OK; mismatch → 403.
sequenceDiagram
participant U as User Browser
participant B as bank.com
participant E as evil.com
U->>B: GET /dashboard (đã có cookie session)
B-->>U: HTML + _csrf token (lưu trong session)
Note over U: User đã login bank.com
U->>E: GET evil.com/phishing
E-->>U: HTML + auto-submit form POST bank.com/transfer
U->>B: POST /transfer (kèm cookie nhưng KHÔNG có _csrf)
B-->>U: 403 Forbidden — CSRF token missing/invalid
Note over E,B: evil.com không đọc được _csrf token<br/>(same-origin policy chặn cross-origin read)Token nên là per-session (đơn giản) hoặc per-request (paranoid — chống replay nhưng phức tạp double-tab). Spring mặc định per-session.
6.3 Double-Submit Cookie Pattern
Pattern thay thế khi không muốn dùng server-side session:
- Server set cookie chứa token random (không HttpOnly để JS đọc được).
- Frontend đọc cookie, copy giá trị vào header
X-XSRF-TOKENcủa request. - Server so sánh cookie value với header value — match → OK.
Lý do an toàn: evil.com không đọc được cookie của origin khác (same-origin policy + SameSite). Khi evil.com submit form, cookie tự gửi nhưng header X-XSRF-TOKEN không có (header phải JS set explicit, mà JS của evil.com không đọc được cookie kia).
Spring config:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
withHttpOnlyFalse() = JS đọc được cookie (cần thiết cho SPA copy giá trị sang header).
6.4 SameSite cookie — phòng thủ hiện đại
RFC 6265bis định nghĩa thuộc tính SameSite cho cookie:
| Giá trị | Browser gửi cookie khi... | Use case |
|---|---|---|
Strict | Request top-level navigate cùng site, không cross-site bất kỳ | Cookie cực nhạy: banking, admin |
Lax (default Chrome 80+) | Top-level navigate cùng site + GET cross-site (link click) | Default an toàn cho hầu hết app |
None | Mọi request, kể cả cross-site | Bắt buộc cho cross-site flow (SSO, embed); phải kèm Secure |
Cấu hình Spring:
@Bean
public CookieSameSiteSupplier sameSiteSupplier() {
return CookieSameSiteSupplier.ofLax();
}
Hoặc khi tự build cookie:
ResponseCookie cookie = ResponseCookie.from("SESSION", sessionId)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.maxAge(Duration.ofHours(1))
.build();
Với SameSite=Lax, browser không gửi cookie với cross-site POST từ form evil.com → CSRF bị chặn ở tầng browser, không cần token. Đây là lý do nhiều framework hiện đại (Rails 7, Django 4) coi CSRF token là defense-in-depth thay vì duy nhất.
Lưu ý: SameSite chưa thay thế hoàn toàn CSRF token vì:
- Browser cũ không support (IE11, Safari < 13 có bug Lax-by-default).
- Vài flow legitimate (SSO callback POST cross-site) cần
SameSite=None→ mất defense. - Defense-in-depth: dùng cả 2 vẫn tốt hơn.
6.5 BREACH attack lưu ý
Token CSRF nhúng trong response HTML có thể bị tấn công BREACH (compression oracle) khi response dùng gzip + có reflected user input. Mitigation:
- Random hóa token mỗi request (per-request token).
- Hoặc thêm masking: token gốc XOR với mask random gắn cùng response → giá trị byte khác mỗi lần dù logic token giống.
Spring Security 6 hỗ trợ XorCsrfTokenRequestAttributeHandler — masking mặc định:
http.csrf(csrf -> csrf
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler())
);
6.6 Spring CSRF default
Spring Security mặc định bật CSRF. Form login kèm CSRF token tự động:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin(Customizer.withDefaults()) // CSRF enable kèm
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
Spring expose token qua request.getAttribute("_csrf"); Thymeleaf template ${_csrf.token} lấy giá trị inject vào hidden input. Validate bằng CsrfFilter ở chain.
7. CSRF cho REST API — disable
http.csrf(csrf -> csrf.disable());
Vì sao disable cho REST API:
- REST API stateless, không session cookie → không có credential auto-attach.
- JWT trong
Authorization: Bearer ...không tự gửi cross-site (header phải JS set explicit). Attacker không thể buộc browser đính kèm. - CSRF chỉ nguy hiểm khi browser tự gửi credential. JWT manual → CSRF impossible by design.
Quy tắc rõ ràng:
| Loại app | Auth credential | CSRF |
|---|---|---|
| REST API stateless + JWT header | Authorization header | Disable |
| Web app cookie session truyền thống | Session cookie | Enable (Spring default) |
| SPA + cookie chứa JWT | HttpOnly cookie | Enable (CookieCsrfTokenRepository) |
| Mixed (admin form + API) | Cả 2 | Enable cho admin chain, disable cho API chain |
7.1 Cookie-based JWT (cần CSRF)
Nếu JWT lưu trong HttpOnly cookie (chống XSS đọc) thay vì Authorization header:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/api/auth/login")
);
Cookie auto-send → CSRF surface trở lại → cần token. ignoringRequestMatchers exempt login endpoint vì user chưa có session → chưa có CSRF token.
Trade-off cookie-based JWT:
- ✅ Chống XSS đánh cắp JWT (HttpOnly chặn
document.cookie). - ❌ Phải xử lý CSRF.
- ❌ Multi-domain phức tạp (cookie scope limit).
8. CORS vs CSRF — bảng phân biệt
| Khía cạnh | CORS | CSRF |
|---|---|---|
| Bản chất | Browser security policy | Attack technique |
| Chiều bảo vệ | Browser chặn JS đọc cross-origin response | Server defend incoming forged request |
| Trigger | Bật mặc định bởi browser (same-origin) | Attacker chủ động gửi request |
| Defense | Server whitelist origin qua header | Token validate per request, hoặc SameSite cookie |
| Affects | Cross-origin browser fetch | Session/cookie-based auth |
| REST API + JWT header | Cần CORS (cross-origin SPA gọi) | Không cần CSRF |
| Web app + session cookie | Same-origin nên thường không cần CORS | Cần CSRF |
| Cookie-based JWT | Cần CORS + credentials | Cần CSRF (cookie auto-attach) |
9. Pattern recommend cho TaskFlow
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CORS — cho phép SPA cross-origin gọi
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// CSRF — disable cho stateless REST API + JWT header
.csrf(csrf -> csrf.disable())
// Stateless session
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// JWT resource server
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
// Auth rules
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://olhub.org"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("X-Total-Count", "Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Pattern này dùng cho TaskFlow Module 05 bài 07 capstone. Stateless + JWT + CORS chuẩn — cấu hình tối thiểu nhưng đủ secure.
10. Pitfall tổng hợp
❌ Pitfall 1: setAllowedOrigins(List.of("*")) đi cùng allowCredentials = true.
✅ Browser reject silent. Dùng exact list hoặc allowedOriginPatterns.
❌ Pitfall 2: Disable CSRF cho web app form-based truyền thống. ✅ Web app session-based → giữ CSRF. Chỉ disable cho REST API stateless.
❌ Pitfall 3: Quên OPTIONS trong allowedMethods.
✅ Spring auto-include OPTIONS, nhưng khai báo explicit không hại — tránh nhầm khi audit.
❌ Pitfall 4: Preflight 401 — auth filter chặn OPTIONS.
✅ Configure CORS qua http.cors(...) của Spring Security — CorsFilter register sớm trong chain.
❌ Pitfall 5: Frontend gọi https://api.olhub.org từ https://www.olhub.org — CORS error vì subdomain khác.
✅ Thêm cả 2 origin, hoặc dùng API gateway proxy về cùng origin.
❌ Pitfall 6: CORS chạy ổn ở dev (localhost) nhưng fail production.
✅ Origins per-environment qua config:
# application-dev.yml
app.cors.allowed-origins: http://localhost:3000
# application-prod.yml
app.cors.allowed-origins: https://olhub.org,https://app.olhub.org
❌ Pitfall 7: SPA quên gửi CSRF token trong PUT/DELETE.
✅ Spring tự nhúng cho form-based; SPA phải đọc token từ cookie hoặc meta tag → set vào header X-XSRF-TOKEN.
❌ Pitfall 8: Đặt SameSite=None mà không kèm Secure.
✅ Browser hiện đại (Chrome 80+) reject combo này → cookie không set được. Luôn cặp SameSite=None; Secure.
❌ Pitfall 9: Cache Access-Control-Allow-Origin ở CDN không có Vary: Origin.
✅ Một origin có thể nhận response chứa Allow-Origin của origin khác → CORS fail bí ẩn. Spring Security tự thêm Vary, nhưng nếu CDN strip header phải config lại.
❌ Pitfall 10: Dùng setAllowedOrigins("https://olhub.org/") (kèm trailing slash).
✅ Origin theo RFC 6454 không có path. Strip slash: "https://olhub.org".
11. 📚 Deep Dive
12. Tóm tắt
- CORS là browser security: chặn JS đọc response cross-origin trừ khi server opt-in qua header.
- Origin =
(scheme, host, port)theo RFC 6454; same-site lỏng hơn (cùng eTLD+1). - Preflight OPTIONS trước "non-simple request" — server respond
Access-Control-Allow-*headers, browser cache theoMax-Age. - Spring CORS config qua
CorsConfigurationSource+http.cors(...)—CorsFilterđăng ký sớm trong chain, preflight bypass auth. allowedOriginsexact list (không wildcard với credentials).allowedOriginPatternscho subdomain động.exposedHeaderscho header custom visible với JS (default chỉ safelisted).- CSRF là tấn công lợi dụng cookie auto-attach. Defense: Synchronizer Token, Double-Submit Cookie, hoặc SameSite cookie.
- REST API stateless + JWT header: disable CSRF (browser không auto-attach Authorization).
- Web app cookie session: enable CSRF (Spring default — Synchronizer Token).
- Cookie-based JWT: enable CSRF với
CookieCsrfTokenRepository+ JS đọc cookie setX-XSRF-TOKEN. - SameSite=Lax mặc định Chrome 80+ — defense-in-depth bổ sung CSRF token, không thay thế hoàn toàn.
- Preflight 401: phải dùng
http.cors(...)để CorsFilter chạy trước AuthorizationFilter. - BREACH: dùng
XorCsrfTokenRequestAttributeHandlerđể mask token tránh compression oracle.
13. Tự kiểm tra
Q1Frontend SPA https://olhub.org fetch API https://api.olhub.org. Browser console hiện "blocked by CORS policy". Diagnose + fix step-by-step?▸
https://olhub.org fetch API https://api.olhub.org. Browser console hiện "blocked by CORS policy". Diagnose + fix step-by-step?Diagnose: server-side CORS misconfig. Browser chặn JS đọc response vì thiếu hoặc sai header Access-Control-Allow-Origin.
Bước 1 — xác định loại request:
- GET không có header custom → simple request, chỉ cần Allow-Origin response header.
- POST JSON / PUT / DELETE / có Authorization header → preflight OPTIONS đi trước.
Bước 2 — verify preflight ở DevTools → Network:
# Tìm OPTIONS request
# Kiểm response headers cần có:
- Access-Control-Allow-Origin: https://olhub.org (echo origin gốc)
- Access-Control-Allow-Methods: gồm method đang gọi
- Access-Control-Allow-Headers: gồm Authorization, Content-Type
- Status: 200 OK
- Vary: Origin (cho cache CDN)
# OPTIONS trả 401 → AuthorizationFilter chặn preflight
# Fix: configure CORS qua Spring Security http.cors(...)Bước 3 — fix Spring config:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CRITICAL — đăng ký CorsFilter sớm
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.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")); // exact origin (không trailing slash)
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("X-Total-Count", "Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Bước 4 — verify từ terminal (loại bỏ browser cache):
curl -i -X OPTIONS https://api.olhub.org/projects \
-H "Origin: https://olhub.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization,Content-Type"
# Expect:
# 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: *
# Access-Control-Allow-Credentials: true
# Access-Control-Max-Age: 3600
# Vary: OriginLỗi hay gặp:
setAllowedOrigins(List.of("*"))+credentials=true→ browser reject silent, fetch promise reject với TypeError.- Trailing slash trong origin (
"https://olhub.org/") — origin không có path, sai spec. - Chỉ dùng
WebMvcConfigurer.addCorsMappings()mà không gọihttp.cors(...)→ Spring Security AuthorizationFilter chặn preflight. - Multi-environment: dev origins khác prod, quên cập nhật khi deploy.
Production checklist:
- Origins: exact list (
olhub.org,app.olhub.org,admin.olhub.org); không wildcard. - Methods: gồm tất cả method đang dùng + OPTIONS.
- Headers:
*nếu trust frontend, exact list nếu strict. - Credentials:
truenếu dùng cookie session phụ trợ. - Max-Age: 3600 production, 0 dev (rapid iteration).
- CDN giữ
Vary: Originheader (không strip).
Q2App có web admin dashboard (cookie session) + REST API (JWT). CSRF setup thế nào? Vẽ multi-chain SecurityFilterChain.▸
Pattern 2 chain — CSRF policy khác nhau theo khu vực:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Chain 1: REST API stateless + JWT header — disable CSRF
@Bean
@Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // disable CSRF
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
// Chain 2: Admin dashboard session-based — enable CSRF (Synchronizer Token)
@Bean
@Order(2)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**")
.csrf(csrf -> csrf
.csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler()) // mask token chống BREACH
)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.formLogin(form -> form
.loginPage("/admin/login")
.defaultSuccessUrl("/admin/dashboard")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/login").permitAll()
.anyRequest().hasRole("ADMIN")
);
return http.build();
}
}Vì sao policy khác nhau:
- REST API + JWT:
- JWT trong
Authorization: Bearer ...header, không phải cookie. - Browser KHÔNG tự attach Authorization cross-site (chỉ cookie có hành vi đó).
- JS phải explicit set header → CSRF impossible by design.
- → Disable CSRF để loại bỏ overhead generate/validate token cho mọi request.
- JWT trong
- Admin web app + cookie session:
- Session ID nằm trong cookie HttpOnly.
- Browser auto-attach cookie cross-site (trừ khi
SameSite=Strict). - evil.com submit form tới admin.olhub.org → cookie đính kèm → server hiểu nhầm là user thật.
- → Cần CSRF token để form hợp lệ phải có giá trị evil.com không đọc được.
Admin form template (Thymeleaf):
<form th:action="@{/admin/users/delete}" method="POST">
<input type="hidden" name="_csrf" th:value="${_csrf.token}" />
<input type="hidden" name="userId" value="42" />
<button>Delete</button>
</form>Spring tự inject attribute _csrf vào model trước render template.
Hardening thêm: SameSite=Strict cho admin cookie:
server:
servlet:
session:
cookie:
same-site: strict
secure: true
http-only: true
name: ADMIN_SESSIONSameSite=Strict + CSRF token = defense-in-depth. Cookie chỉ gửi khi cùng site → browser hiện đại đã chặn CSRF ở tầng dưới, token thành layer bảo hiểm.
Cookie-based JWT — pattern thay thế nếu cần HttpOnly:
@Bean
public SecurityFilterChain mixedChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/spa/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/spa/auth/login") // login chưa có session → không có CSRF token
)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}Lý do approach này:
- JWT trong HttpOnly cookie chống XSS đọc (
document.cookiekhông đọc được). - Cookie auto-attach → CSRF surface trở lại → cần token.
- SPA đọc CSRF token từ cookie không-HttpOnly (
XSRF-TOKEN) → set headerX-XSRF-TOKENmỗi request side-effect.
Recommend cho TaskFlow:
- Mặc định (TaskFlow Module 05 v3): JWT trong Authorization header + CSRF disable. Đơn giản, modern, không phải lo SameSite/CSRF rotation.
- Admin dashboard tách riêng: session-based + CSRF enable + SameSite=Strict.
- Hybrid (cookie JWT): CSRF với CookieCsrfTokenRepository — chỉ dùng khi cần HttpOnly XSS protection (ví dụ embed widget không kiểm soát XSS đầu đọc storage).
Q3setAllowedOrigins(List.of("*")) với allowCredentials = true — browser reject silent. Vì sao spec cấm? Liệt kê 3 workaround + so sánh.▸
setAllowedOrigins(List.of("*")) với allowCredentials = true — browser reject silent. Vì sao spec cấm? Liệt kê 3 workaround + so sánh.Vì sao spec cấm:
CORS spec (Fetch Standard) cấm rõ wildcard origin * đi cùng credentials = true trong response:
- Wildcard origin = "bất kỳ site nào cũng có quyền đọc response".
- Credentials = true = "kèm cookie / Authorization header trong request".
- Tổ hợp = MỌI website attacker có thể gửi request đã authenticated + đọc response → toàn bộ session/JWT user lộ ra → universal account takeover.
Browser reject behavior:
# Server trả response:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# Browser console:
"The value of the 'Access-Control-Allow-Origin' header in the response must
not be the wildcard '*' when the request's credentials mode is 'include'."
# Request fail silent ở response read level
fetch("https://api.olhub.org/projects", { credentials: "include" })
.then(r => r.json())
.catch(err => console.log(err)); // TypeError: Failed to fetchLưu ý: request POST đã đến server trước khi browser kiểm response (vì SOP không chặn request). Server có thể đã write data → cần idempotency key để rollback double-submit.
3 workaround + trade-off:
Workaround 1 — Specific origins list (recommend cho prod ổn định):
config.setAllowedOrigins(List.of(
"https://olhub.org",
"https://app.olhub.org",
"https://admin.olhub.org"
));
config.setAllowCredentials(true); // OK với specific origins- ✅ Secure, explicit, audit-friendly.
- ❌ Phải maintain list per-environment, deploy mỗi khi thêm subdomain.
Workaround 2 — allowedOriginPatterns (Spring 5.3+ cho subdomain động):
config.setAllowedOriginPatterns(List.of(
"https://*.olhub.org",
"http://localhost:[*]" // mọi port localhost (dev)
));
config.setAllowCredentials(true);- ✅ Cho subdomain mới mà không deploy lại.
- ✅ Spring resolve pattern → match Origin → echo lại exact origin (không trả wildcard) → browser accept với credentials.
- ❌ Pattern quá rộng (
*.com) là bug → mọi site attacker tự đăng ký subdomain match được.
Workaround 3 — Dynamic origin reflection (multi-tenant SaaS):
@Component
public class DynamicCorsConfigurationSource implements CorsConfigurationSource {
private final TenantRepository tenantRepo;
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
String origin = request.getHeader("Origin");
if (origin == null) return null;
boolean trusted = tenantRepo.findByCustomDomain(origin).isPresent()
|| origin.endsWith(".olhub.org");
if (!trusted) return null; // null → CorsFilter reject
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(origin)); // echo back exact origin
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
return config;
}
}- ✅ Multi-tenant SaaS có custom domain user-managed.
- ✅ Validate origin match business rule (tenant table).
- ❌ Phức tạp, phải care performance (gọi DB mỗi preflight) → cache origin lookup (Caffeine, TTL 5 phút).
- ❌ Bug ở rule check = catastrophic.
Public API không credentials — wildcard OK:
# Public read-only API, không cookie/auth
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(false); // anonymous read-only
config.setAllowedMethods(List.of("GET"));Wildcard + credentials=false là pattern hợp lệ (vd public stat API, opensource data). Spec chỉ cấm tổ hợp wildcard + credentials.
Recommend cho TaskFlow:
- Production single-tenant: specific origins list. Strict, simple.
- Multi-tenant SaaS: dynamic origin với tenant whitelist + cache.
- Subdomain pattern fixed:
allowedOriginPatternsvớihttps://*.olhub.org, không bao giờ*.com. - Public widget API: wildcard + credentials=false, GET only.
Q4Giải thích Synchronizer Token Pattern vs Double-Submit Cookie Pattern. Khi nào dùng cái nào? SameSite=Lax mặc định Chrome 80+ có thay thế CSRF token không?▸
Synchronizer Token Pattern (server-side state):
- User login → server tạo session, lưu CSRF token random vào session storage (Redis, in-memory).
- Server render trang → embed token vào hidden input form (hoặc meta tag SPA).
- Form submit → server đọc token từ body/header, so sánh với token lưu trong session của user đó.
- Match → OK. Mismatch → 403.
Spring Security default. Storage: HttpSessionCsrfTokenRepository.
- ✅ Strong: token chỉ tồn tại server-side (không lộ qua client).
- ✅ Per-session đơn giản; per-request paranoid.
- ❌ Cần server-side session → khó scale stateless.
Double-Submit Cookie Pattern (stateless):
- Server set cookie chứa token random, KHÔNG HttpOnly (để JS đọc được).
- Frontend đọc cookie → set giá trị vào header
X-XSRF-TOKENmỗi request side-effect. - Server so sánh giá trị cookie với header. Match → OK.
Spring Security: CookieCsrfTokenRepository.withHttpOnlyFalse().
- ✅ Stateless — không cần server session.
- ✅ Hoạt động với CDN/cluster đa node.
- ❌ Yếu hơn Synchronizer nếu có lỗ XSS subdomain (subdomain XSS có thể set cookie root domain → forge token).
- ❌ Cần secure cookie path/domain scope cẩn thận.
Bảng quyết định:
| Tình huống | Pattern khuyến nghị |
|---|---|
| Web app monolith + session cookie | Synchronizer Token (Spring default) |
| SPA + cookie-based JWT (HttpOnly) | Double-Submit Cookie |
| REST API + JWT Authorization header | Không cần CSRF (disable) |
| Microservice cluster, không sticky session | Double-Submit Cookie |
| Cực kỳ nhạy cảm (banking transaction) | Synchronizer per-request token + masking + SameSite=Strict |
SameSite=Lax có thay thế CSRF token không?
Câu trả lời ngắn: không hoàn toàn. Lax là defense-in-depth, không phải thay thế.
SameSite=Lax (Chrome 80+ default cho cookie không khai báo) chặn:
- ✅ Cross-site POST/PUT/DELETE form submit (kịch bản CSRF kinh điển).
- ✅ Cross-site fetch không simple.
SameSite=Lax KHÔNG chặn:
- ❌ Top-level navigation GET cross-site (link click) → cookie vẫn gửi → vulnerable nếu GET có side-effect (anti-pattern nhưng tồn tại).
- ❌ Browser cũ không support: IE11, Safari pre-13, Android browser pre-2019.
- ❌ Subdomain XSS có thể trigger same-site request.
- ❌ Server-side request forgery (SSRF) bypass hoàn toàn (không qua browser).
Recommend stack hoàn chỉnh:
- Layer 1 — SameSite cookie: đặt
SameSite=StricthoặcLaxcho cookie session. Chặn 90% CSRF ở tầng browser. - Layer 2 — CSRF token: Synchronizer hoặc Double-Submit. Bắt 10% còn lại (browser cũ, edge case).
- Layer 3 — Origin/Referer header check: reject request thiếu Origin hoặc Origin không trong whitelist. Defense thêm cho legacy browser.
- Layer 4 — Method discipline: GET không có side-effect (REST principle). PUT/DELETE/POST mới state-changing → CSRF chỉ care nhóm này.
Spring Security 6 hỗ trợ Layer 2-4 mặc định khi bật http.csrf(...). Layer 1 phải config cookie attribute riêng.
Q5Trace filter chain Spring Security: tại sao đặt http.cors(...) ở đầu mà preflight vẫn bị 401? Debug step-by-step.▸
http.cors(...) ở đầu mà preflight vẫn bị 401? Debug step-by-step.Tình huống: bạn đã gọi http.cors(...) nhưng OPTIONS preflight vẫn trả 401. Có vài nguyên nhân.
Step 1 — Bật log để xem filter order thật:
# application.yml
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: TRACE
org.springframework.web.cors: TRACEThực hiện request, sẽ thấy log dạng:
Securing OPTIONS /api/projects
Invoking DisableEncodeUrlFilter (1/15)
Invoking WebAsyncManagerIntegrationFilter (2/15)
Invoking SecurityContextHolderFilter (3/15)
Invoking HeaderWriterFilter (4/15)
Invoking CorsFilter (5/15) ← phải xuất hiện TRƯỚC AuthorizationFilter
...
Invoking AuthorizationFilter (15/15)Nếu thấy AuthorizationFilter log trước CorsFilter → cấu hình sai.
Step 2 — Kiểm các nguyên nhân thường gặp:
Nguyên nhân A — Có nhiều SecurityFilterChain bean, chain đứng trước không có cors():
@Bean
@Order(1)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http.securityMatcher("/web/**") // matcher rộng hơn dự định
.formLogin(...)
.authorizeHttpRequests(...);
// ⚠️ Không có .cors() và matcher catch luôn /api/**
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.cors(...) // không bao giờ chạy vì webChain match trước
...;
return http.build();
}Fix: kiểm securityMatcher chính xác, không chồng lấn. @Order(1) match TRƯỚC nên matcher phải narrow hơn hoặc loại trừ /api/**.
Nguyên nhân B — Custom filter chèn trước CorsFilter:
http.addFilterBefore(myCustomFilter, CorsFilter.class)
.cors(...)
.authorizeHttpRequests(...);
// myCustomFilter chạy trước CorsFilter → có thể trả 401 sớmFix: dùng addFilterAfter(myCustomFilter, CorsFilter.class) hoặc đặt sau BearerTokenAuthenticationFilter.
Nguyên nhân C — Reverse proxy strip Origin header:
# Nginx config thiếu:
proxy_pass_request_headers on;
proxy_set_header Origin $http_origin;CorsFilter không thấy Origin → không match config → preflight fall through → AuthorizationFilter trả 401.
Fix: đảm bảo proxy forward Origin header. Verify bằng:
# Tại app trực tiếp:
curl -i -X OPTIONS http://localhost:8080/api/projects \
-H "Origin: https://olhub.org" \
-H "Access-Control-Request-Method: POST"
# Tại proxy:
curl -i -X OPTIONS https://api.olhub.org/projects \
-H "Origin: https://olhub.org" \
-H "Access-Control-Request-Method: POST"
# Compare 2 response. Nếu khác → proxy strip header.Nguyên nhân D — Origin không trong allowedOrigins:
Subtle case: dev gõ http://localhost:3000 trong allowedOrigins, nhưng SPA chạy http://127.0.0.1:3000 → mismatch hostname → CorsFilter reject preflight → fall through tới auth → 401.
Fix: thêm cả 2 hostname, hoặc dùng pattern http://localhost:[*] + http://127.0.0.1:[*].
Nguyên nhân E — setAllowedHeaders không gồm header browser thực tế gửi:
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
// Frontend gửi thêm "X-Trace-Id" → preflight reject
// Browser console: Header X-Trace-Id is not allowed by Access-Control-Allow-HeadersFix: thêm header thiếu, hoặc dùng List.of("*") cho dev.
Step 3 — Verification matrix:
| Test | Kỳ vọng | Nếu fail |
|---|---|---|
| Log thấy CorsFilter trước AuthorizationFilter | Có | Reorder filter / kiểm chain conflict |
| OPTIONS trả 200 trực tiếp tại app | 200 + Allow-* headers | Kiểm allowedOrigins/Methods/Headers |
| OPTIONS qua proxy giống tại app | Identical | Proxy forward Origin header |
| Browser DevTools network preflight | 200 | Kiểm Origin browser gửi vs allowedOrigins |
| Vary: Origin trong response | Có | CDN/cache config |
Step 4 — Pre-prod CORS smoke test:
#!/bin/bash
# scripts/cors-smoke.sh
ORIGIN="https://olhub.org"
API="https://api.olhub.org"
for path in /api/projects /api/auth/login /api/users/me; do
echo "=== OPTIONS $path ==="
curl -s -o /dev/null -w "Status: %{http_code}\n" \
-X OPTIONS "$API$path" \
-H "Origin: $ORIGIN" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization,Content-Type"
doneChạy script trước mỗi deploy. Bất kỳ status ≠ 200 → block deploy.
Tóm tắt nguyên nhân thường gặp ở Spring Security 6:
- Multiple SecurityFilterChain với matcher chồng lấn → chain không có cors() match trước.
- Custom filter chèn trước CorsFilter qua addFilterBefore.
- Reverse proxy strip Origin header.
- Origin browser gửi không trong allowedOrigins (localhost vs 127.0.0.1, trailing slash, port khác).
- Header browser gửi không trong allowedHeaders.
- Cấu hình CORS qua WebMvcConfigurer mà không gọi http.cors() → CorsFilter không đăng ký vào Security chain.
Bài tiếp theo: Mini-challenge — TaskFlow v3 với JWT + RBAC
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...