Bỏ qua để đến nội dung
Knowledge Hub

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

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

Cookie hợp lệ không có nghĩa là người dùng chủ ý gửi request
Cookie hợp lệ không có nghĩa là người dùng chủ ý gửi requestBước 1/4: Người dùng đăng nhập; browser giữ session cookie của bank.example.Browserđã đăng nhậpbank.exampleevil.exampletrang lạcookie
Browser → bank.examplecookie
Bước 1/4

Người dùng đăng nhập; browser giữ session cookie của bank.example.

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.

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 POST truyề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.
  • fetch với Content-Type: application/json hoặ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-Type là 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
  • Với credentialed request, Access-Control-Allow-Origin: * bị browser từ chối. Allow-Headers, Allow-Methods, Expose-Headers cũ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 Origin vô đ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-Age cho 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.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-csrf-token
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token
Vary: Origin

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.

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 được
import { 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.

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
Terminal window
npx --prefix examples ts-node examples/src/csrf/csrf-demo.ts

Demo 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.

  1. 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.
  2. Cookie khai tường minh HttpOnly; Secure; SameSite=Lax (hoặc Strict nếu luồng cho phép).
  3. GET, HEAD, OPTIONS không đổi trạng thái.
  4. Reject Sec-Fetch-Site: cross-site cho method không an toàn; fallback kiểm tra Origin khi header vắng.
  5. CORS dùng allowlist origin chính xác, không * với credentials, có Vary: Origin.
  6. Token ràng với session (HMAC hoặc lưu server-side), không đặt trong URL.
  7. Form login cũng có token; đổi session ID sau khi đăng nhập.
  8. Test bằng browser thật từ origin lạ, không chỉ curl.

Nhiều lớp bảo vệ

  • Fetch Metadata và Origin khô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=Strict là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.

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?