Cross-Site Request Forgery (CSRF) là khi một trang độc khiến trình duyệt của bạn gửi một yêu cầu tới một site khác mà bạn đang đăng nhập — và site đó thực thi nó, vì trình duyệt tự động đính kèm cookie phiên của bạn. Bài này dựng đúng tình huống ấy: một "ngân hàng" có phiên đăng nhập thật, một trang tấn công ở tên miền khác, và một trình duyệt Chromium thật ở giữa. Rồi đo hai cách phòng thủ — CSRF token và cookie SameSite — xem cái nào chặn được, trong hoàn cảnh nào.

Toàn bộ chạy trên hai ứng dụng tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.

CSRF: token va SameSite, do bang trinh duyet that voi hai ten mien

Cách đo

bank:5000 giữ số dư của tài khoản an (bắt đầu 1000) và có route POST /chuyen trừ tiền. evil:5001 phục vụ một trang chứa form tự động gửi POST tới bank:5000/chuyen ngay khi tải. Trình duyệt đăng nhập thật vào ngân hàng, rồi mở trang của evil. Nếu số dư tụt xuống 500, tấn công thành công — trình duyệt đã gửi yêu cầu chuyển tiền kèm cookie phiên, dù yêu cầu khởi từ trang của kẻ tấn công.

Số đo: token so với SameSite trên POST

Ba lần chạy, kết quả giống hệt:

Cấu hình Số dư sau Kết quả
Không bảo vệ, cookie SameSite=None 500 mất tiền
Không bảo vệ, cookie SameSite=Lax 1000 chặn
Không bảo vệ, cookie SameSite=Strict 1000 chặn
CSRF token, cookie SameSite=None 1000 chặn

Điều đáng nhớ

CSRF sống được là nhờ cookie tự động gửi kèm. Dòng đầu bảng cho thấy cơ chế trần trụi: với SameSite=None, trình duyệt đính cookie phiên vào mọi yêu cầu tới ngân hàng, kể cả yêu cầu do trang evil kích hoạt. Kẻ tấn công không cần đọc cookie, không cần biết mật khẩu — nó chỉ cần khiến trình duyệt bạn gửi yêu cầu, và cookie tự đi theo. Đó là toàn bộ bản chất của CSRF.

SameSite=Lax một mình đã chặn CSRF cho POST — không cần token. Đây là thay đổi lớn nhất của web mười năm qua, và số đo xác nhận nó. Lax bảo trình duyệt: đừng gửi cookie này khi yêu cầu POST đến từ một site khác. Kết quả, form của evil gửi đi mà không mang cookie phiên, ngân hàng thấy "chưa đăng nhập", và từ chối. Từ 2020, Chrome đặt Lax làm mặc định cho cookie không khai SameSite — nghĩa là rất nhiều ứng dụng được bảo vệ khỏi CSRF mà tác giả không viết một dòng nào.

CSRF token chặn ngay cả khi SameSite=None. Token là một giá trị bí mật ngẫu nhiên gắn với phiên, phải gửi kèm trong thân yêu cầu. Trang evil không đọc được token của ngân hàng (chính sách same-origin cấm nó đọc phản hồi từ tên miền khác), nên nó chỉ đoán mò và bị từ chối. Đây là lớp phòng thủ độc lập với cookie: nó chặn được cả khi cookie bị gửi kèm.

Một chi tiết trong cách đo đáng nói: trang tấn công của tôi gửi một trường csrf — nhưng với giá trị "doan-mo-khong-biet", một chuỗi bịa. Ngân hàng so nó với token thật gắn trong phiên và trả về 403. Điều này mô phỏng đúng thực tế: kẻ tấn công biết có một trường token cần điền, nhưng không có cách nào lấy được giá trị đúng, vì đọc phản hồi từ tên miền khác bị chính trình duyệt chặn. Token không mạnh vì bí mật của nó khó đoán về mặt toán học — nó mạnh vì same-origin policy khiến kẻ tấn công không đọc nổi trang chứa nó.

Chỗ SameSite=Lax không đủ

Đây là phần dễ kết luận sai. Nhìn bảng trên, dễ tin "bật SameSite=Lax là xong CSRF". Tôi đo thêm để bác bỏ. Thêm một route GET /chuyen-get đổi trạng thái — một thiết kế sai nhưng cực kỳ phổ biến — rồi tấn công nó hai cách khác nhau, với cookie Lax:

Tấn công route GET, cookie Lax Số dư sau Kết quả
Điều hướng top-level (window.location) 500 mất tiền
Sub-resource (<img src>) 1000 chặn
Điều hướng top-level, cookie Strict 1000 chặn

SameSite=Lax để lọt khi kẻ tấn công dùng điều hướng top-level — ép trình duyệt tự chuyển cả trang tới URL của ngân hàng. Lý do nằm trong định nghĩa của Lax: nó cho phép gửi cookie khi người dùng điều hướng tới site ở cấp cao nhất (bấm link, window.location), vì đó thường là hành vi hợp pháp. Nhưng nếu route GET ấy đổi trạng thái, "điều hướng hợp pháp" trở thành đòn tấn công. Strict chặn cả điều này, đổi lại nó phá vỡ cả những link hợp pháp từ site ngoài.

Bài học kép: Lax chỉ an toàn khi mọi hành động đổi trạng thái đều là POST (hoặc PUT/DELETE), không bao giờ là GET. Và đó cũng là một nguyên tắc thiết kế cũ hơn CSRF: GET phải không có tác dụng phụ.

Vì sao

Hai cách phòng thủ tấn công vào hai mắt xích khác nhau của CSRF:

  • SameSite cắt ở tầng cookie: nó ngăn cookie được gửi kèm yêu cầu cross-site ngay từ đầu. Không cần ứng dụng làm gì — trình duyệt tự lo. Nhưng nó phụ thuộc vào phân loại của trình duyệt về "cross-site", và phân loại đó có ngoại lệ (điều hướng top-level với Lax).
  • CSRF token cắt ở tầng ứng dụng: nó đòi một bí mật mà chỉ trang thật của bạn có. Nó không quan tâm cookie đến từ đâu; nó chỉ chấp nhận yêu cầu mang đúng token. Bù lại, ứng dụng phải tự sinh, gắn, và kiểm token ở mọi form.

Chúng bổ sung cho nhau, không thay thế nhau. SameSite=Lax là lớp mặc định miễn phí; token là lớp không phụ thuộc vào hành vi trình duyệt.

Nghĩa là gì trong thực tế

  • Đặt SameSite=Lax (hoặc Strict) cho cookie phiên — nếu framework của bạn chưa làm mặc định. Đây là dòng cấu hình đem lại nhiều bảo mật nhất cho công sức bỏ ra.
  • Không bao giờ để GET đổi trạng thái. Chuyển tiền, xoá tài khoản, đổi mật khẩu — tất cả phải là POST/PUT/DELETE. Đây vừa là chống CSRF, vừa là thiết kế HTTP đúng.
  • Giữ CSRF token cho các luồng nhạy cảm, đặc biệt nếu bạn cần SameSite=None (ví dụ API bị nhúng cross-site có chủ đích) hoặc phải hỗ trợ trình duyệt cũ không hiểu SameSite.
  • Đừng chỉ dựa vào một lớp. Lax có lỗ (route GET), token có thể bị quên ở một form. Có cả hai thì một lớp sót, lớp kia vẫn đỡ.

Chỗ tôi không kết luận được

Tôi đo trên HTTP nội bộ Docker, không HTTPS. SameSite=None bắt buộc đi kèm Secure trên trình duyệt thật qua HTTPS — tôi phải tắt yêu cầu đó để đo trong môi trường HTTP, nên hành vi None ở đây là phiên bản nới lỏng. Cơ chế thì không đổi.

Tôi cũng không đo các biến thể tinh vi hơn: CSRF qua <form enctype> để né kiểm Content-Type, hay tấn công dựa trên việc đọc header Referer/Origin phía máy chủ (một cách phòng thủ thứ ba mà tôi bỏ qua). Và tôi không chạm tới CORS — nó là chủ đề gần kề nhưng khác: CORS về đọc phản hồi cross-site, CSRF về gửi yêu cầu cross-site.

Thử ba mươi giây

Mở công cụ nhà phát triển, tab Application, xem cookie phiên của một site bạn dùng. Cột SameSite ghi gì?

Nếu trống hoặc None, site đó dựa hoàn toàn vào CSRF token (hoặc không có gì). Nếu Lax, hãy tự hỏi tiếp: có route GET nào đổi trạng thái không? Vì đó chính là cái khe mà Lax để lại — và là cái khe mà số đo trong bài này lấy mất 500 đồng.