CORS và CSRF: chặn đọc response chưa phải chặn request Trung cấp
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Người dùng đang đăng nhập bank.example bằng session cookie, rồi mở một trang do kẻ tấn công kiểm soát. Trang đó tự submit một form POST tới endpoint chuyển tiền. Nếu browser gắn cookie vào request và server chỉ kiểm tra cookie, tiền bị chuyển.
Trang lạ không đọc được response (same-origin policy), nhưng điều đó không quan trọng: side effect đã xảy ra. Đây là CSRF (cross-site request forgery).
cookieNgười dùng đăng nhập; browser giữ session cookie của bank.example.
Tóm tắt nhanh
Phần tiêu đề “Tóm tắt nhanh”| CORS | CSRF | |
|---|---|---|
| Là gì | Cơ chế nới same-origin policy: server cho phép script ở origin khác đọc response | Một cuộc tấn công: request ngoài ý muốn mang cookie của nạn nhân |
| Ai thực thi | Browser | Server phải tự kiểm tra |
| Chặn được request? | Không (trừ request cần preflight) | Chỉ khi server có token / kiểm tra nguồn |
| Cấu hình sai gây ra | Origin lạ đọc được dữ liệu của người dùng | Hành động bị thực hiện thay người dùng |
Hai ý cần nhớ: CORS không phải lớp chống CSRF; và curl không thi hành CORS hay SameSite nên không dùng nó để test hai thứ này.
Simple request và preflight
Phần tiêu đề “Simple request và preflight”Browser chia cross-origin request thành hai loại. Request simple được gửi thẳng; browser chỉ chặn script đọc response nếu server không cho phép. Request preflighted phải qua một OPTIONS trước.
Đang tải diagram…
Hai hệ quả thực tế:
- Form
POSTtruyền thống (application/x-www-form-urlencoded) là simple request, và form HTML thì không qua CORS: nó được gửi bất kể header CORS của server. fetchvớiContent-Type: application/jsonhoặc header tùy ý bị preflight. Vì vậy API chỉ nhận JSON được bảo vệ một phần, nhưng đừng coi đó là biện pháp CSRF duy nhất: nếu server parse JSON dùContent-Typelàtext/plain, request simple vẫn lọt.
| Simple POST (form) | POST JSON qua fetch |
|
|---|---|---|
Content-Type |
application/x-www-form-urlencoded |
application/json |
| Preflight | Không | Có |
| Server bị chạm khi CORS không cho phép | Có, request vẫn tới | Không, chỉ OPTIONS tới |
Cấu hình CORS đúng
Phần tiêu đề “Cấu hình CORS đúng”- Với credentialed request,
Access-Control-Allow-Origin: *bị browser từ chối.Allow-Headers,Allow-Methods,Expose-Headerscũng không được dùng*. Phải trả origin cụ thể vàAccess-Control-Allow-Credentials: true. - Khi phản hồi origin theo allowlist động, thêm
Vary: Originđể cache không phát nhầm response cho origin khác. - Không echo lại header
Originvô điều kiện; so với allowlist chính xác (không dùng regex subdomain lỏng). - Preflight không bao giờ mang credentials.
Access-Control-Max-Agecho phép cache kết quả preflight; mặc định chỉ 5 giây và mỗi browser có trần riêng.
OPTIONS /transfer HTTP/1.1Origin: https://app.example.comAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type,x-csrf-token
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: trueAccess-Control-Allow-Methods: POSTAccess-Control-Allow-Headers: Content-Type, X-CSRF-TokenVary: OriginCookie và SameSite
Phần tiêu đề “Cookie và SameSite”SameSite quyết định cookie có đi kèm request cross-site hay không. Nó xét site (registrable domain + scheme), không xét origin: app.example.com và api.example.com khác origin nhưng cùng site.
| Giá trị | Cookie được gửi khi |
|---|---|
Strict |
Chỉ request same-site |
Lax |
Same-site, và cross-site khi là top-level navigation (đổi URL trên thanh địa chỉ, gồm click link, submit form) và method an toàn (không phải POST/PUT/DELETE). Không gửi với fetch, <img>, <iframe> |
None |
Mọi request; bắt buộc kèm Secure |
Về mặc định, MDN ghi rằng một số browser coi cookie không khai SameSite là Lax (Chrome làm vậy từ 2020). Đừng giả định mọi browser giống nhau: luôn khai SameSite tường minh.
Khi Lax là mặc định (không khai tường minh), Chrome có thêm ngoại lệ: cookie vẫn được gửi với cross-site POST nếu cookie được set chưa quá 2 phút trước. Khai SameSite=Lax tường minh thì không có ngoại lệ này.
Lax chặn form POST từ trang lạ, nhưng không bảo vệ:
- Endpoint thay đổi trạng thái bằng
GET(link hoặc điều hướng cũng gắn cookie). - Request xuất phát từ subdomain cùng site mà bạn không kiểm soát.
- Browser không áp mặc định
Lax.
OWASP coi SameSite là defense in depth, không thay CSRF defense riêng.
Các lớp chống CSRF
Phần tiêu đề “Các lớp chống CSRF”Dùng ít nhất hai lớp cho mọi endpoint đổi trạng thái dùng cookie:
| Lớp | Cách làm | Lưu ý |
|---|---|---|
| Fetch Metadata | Từ chối method không an toàn khi Sec-Fetch-Site: cross-site |
Header do browser đặt, JS không sửa được; OWASP gọi là tín hiệu chính. Cần fallback sang Origin cho client cũ |
| Origin check | Có Origin thì phải khớp origin của bạn |
Thiếu hoặc null không chứng minh same-origin |
| CSRF token | Synchronizer token (lưu theo session ở server) hoặc signed double-submit | Token không đặt trong URL |
| Custom header | SPA gửi header riêng như X-CSRF-Token, buộc browser preflight |
CORS phải chặt, không mở regex subdomain |
| SameSite | Lax hoặc Strict tường minh |
Chỉ là lớp bổ sung |
Về double-submit cookie: dạng ngây thơ (so cookie với header, hai giá trị tùy ý) không an toàn vì kẻ tấn công đặt được cookie qua subdomain. OWASP khuyên dạng signed: token là HMAC của giá trị gắn với session bằng secret phía server. Chỉ ký mà không gắn session thì gần như vô dụng.
// Token = HMAC(secret, sessionId) — gắn với session, kẻ tấn công không tự tạo đượcimport { createHmac, timingSafeEqual } from 'node:crypto';
const csrfToken = (sessionId: string) => createHmac('sha256', process.env.CSRF_SECRET!).update(sessionId).digest('hex');
function verify(sessionId: string, header: string | undefined): boolean { if (!header) return false; const a = Buffer.from(csrfToken(sessionId)); const b = Buffer.from(header); return a.length === b.length && timingSafeEqual(a, b);}Hai việc khác dễ quên: không dùng GET để đổi trạng thái, và bảo vệ cả form login (login CSRF) bằng pre-session token.
Khi nào cần, khi nào không
Phần tiêu đề “Khi nào cần, khi nào không”| Tình huống | CSRF defense? |
|---|---|
| Web/SPA xác thực bằng cookie, có endpoint đổi dữ liệu | Cần, token hoặc Fetch Metadata + SameSite |
API chỉ nhận Authorization: Bearer do client tự gắn, không dùng cookie |
Thường không cần cho CSRF; vẫn phải lo XSS và nơi lưu token |
| Webhook server-to-server | Không dùng CSRF token; xác minh chữ ký và idempotency |
| API công khai, đọc không cần credentials | Không cần credentialed CORS; * là chấp nhận được |
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”npx --prefix examples ts-node examples/src/csrf/csrf-demo.tsDemo kiểm tra quyết định phía server với session giả: token đúng được chấp nhận (true), thiếu token (false) và token ngẫu nhiên khác (false) bị từ chối. Demo không có browser nên không minh họa SameSite hay CORS.
Checklist production
Phần tiêu đề “Checklist production”- Mọi endpoint đổi trạng thái dùng cookie đều qua một lớp CSRF (token hoặc Fetch Metadata), không route nào “được miễn” mà không có lý do.
- Cookie khai tường minh
HttpOnly; Secure; SameSite=Lax(hoặcStrictnếu luồng cho phép). GET,HEAD,OPTIONSkhông đổi trạng thái.- Reject
Sec-Fetch-Site: cross-sitecho method không an toàn; fallback kiểm traOriginkhi header vắng. - CORS dùng allowlist origin chính xác, không
*với credentials, cóVary: Origin. - Token ràng với session (HMAC hoặc lưu server-side), không đặt trong URL.
- Form login cũng có token; đổi session ID sau khi đăng nhập.
- Test bằng browser thật từ origin lạ, không chỉ
curl.
Trade-offs
Phần tiêu đề “Trade-offs”Nhiều lớp bảo vệ
- Fetch Metadata và
Originkhông cần lưu state, rẻ để triển khai. - Token ràng session chặn form giả kể cả khi cookie policy bị cấu hình lỏng.
- CORS chặt giảm bề mặt đọc dữ liệu cross-origin.
Chi phí
- Token theo request mạnh hơn nhưng làm form cũ và nút back khó dùng.
SameSite=Strictlàm mất đăng nhập khi người dùng vào từ link ngoài.- XSS hoặc subdomain không tin cậy vô hiệu nhiều giả định; phải xử lý riêng.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao trang lạ gây được side effect khi không đọc được response? (2) Request nào của fetch bị preflight, request nào không? (3) Vì sao double-submit cookie ngây thơ không an toàn?