Tưởng tượng cái vòng tay ở khu nghỉ dưỡng: đeo vào là bạn "trả tiền" cho bất cứ thứ gì bằng cách chìa cổ tay ra, không cần ký, không cần mã PIN. Tiện — cho tới khi ai đó nắm lấy tay bạn và chìa hộ vào máy quẹt. Cái vòng vẫn là của bạn, giao dịch vẫn hợp lệ dưới mắt hệ thống, nhưng ý muốn thì không phải của bạn. Cookie phiên trong trình duyệt hoạt động y hệt cái vòng đó: trình duyệt tự chìa nó ra cho mọi yêu cầu gửi tới ngân hàng, kể cả yêu cầu mà một trang khác xúi nó gửi. Đó chính là Cross-Site Request Forgery, và bài này tôi dựng đúng cái cảnh "bị nắm tay chìa hộ" ấy bằng một trình duyệt thật để đo xem cách phòng thủ nào chặn được.

Cụ thể: 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, một Chromium thật ở giữa, rồi đo hai lá chắn — 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.

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

Ba điều bảng này phơi ra

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 của bạn gửi yêu cầu, và cookie tự đi theo. Đó là toàn bộ bản chất của CSRF, đúng như bàn tay bị nắm chìa hộ cái vòng.

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. Có một chi tiết trong cách đo đáng nói: trang tấn công của tôi có 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 ở đây: 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 cả CSRF: GET phải không có tác dụng phụ.

Vì sao hai lá chắn bổ sung cho nhau

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ì vì 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ó, không quan tâm cookie đến từ đâu, 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 chứ không thay thế: SameSite=Lax là lớp mặc định gần như miễn phí, còn token là lớp không phụ thuộc vào hành vi trình duyệt.

Ra thực tế thì gọn thế này. Đặ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 trên mỗi đơn vị công sức. Không bao giờ để GET đổi trạng thái: chuyển tiền, xoá tài khoản, đổi mật khẩu đều phải là POST/PUT/DELETE, vừa 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 khi 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. Và đừ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 đỡ.

Mẫu số chung

Cái gốc của CSRF là một khái niệm cũ hơn web nhiều: thẩm quyền ngầm (ambient authority) — hệ thống hành động theo danh tính đi kèm tự động thay vì bằng chứng về ý muốn. Nó là bài toán "phó quan bị lừa" (confused deputy), và nó hiện ra ở mọi tầng.

Trong thế giới Java, đây đúng là hai công tắc trong Spring Security: CsrfToken (bật mặc định, dùng mẫu synchronizer-token hoặc double-submit) và server.servlet.session.cookie.same-site=lax. Ai từng viết một API stateless bằng JWT trong header rồi tự hỏi "có cần CSRF token không" đang chạm đúng lõi: CSRF chỉ tồn tại khi thẩm quyền đi kèm tự động như cookie — token trong header là bằng chứng tường minh nên miễn nhiễm, đó là lý do Spring cho tắt CSRF ở các endpoint chỉ nhận Bearer token.

Trong Go, gorilla/csrf (hay nosurf) cài đúng mẫu double-submit token, còn http.Cookie{SameSite: http.SameSiteLaxMode} là công tắc kia. Cùng bài học: chọn đặt danh tính ở đâu quyết định bạn có dính CSRF hay không — cookie thì dính, header do chính JS của bạn đặt thì không.

Còn ở cơ sở dữ liệu, cùng cái bẫy thẩm quyền ngầm mang tên khác: một hàm SECURITY DEFINER trong PostgreSQL chạy với quyền của người tạo chứ không phải người gọi — tiện, nhưng nếu nó nhận đầu vào không kiểm thì kẻ gọi mượn luôn quyền cao đó, đúng kiểu "phó quan bị lừa". Câu trả lời cũng cùng dạng: đừng để quyền đi kèm ngầm quyết định hành động, hãy đòi bằng chứng tường minh về việc ai đang thật sự yêu cầu điều gì.

Mẫu số chung: mọi lỗ hổng kiểu CSRF sinh ra khi hệ thống lẫn 'yêu cầu này mang danh tính hợp lệ' với 'chủ nhân danh tính đó thật sự muốn điều này' — và cách chặn luôn là đòi một bằng chứng về ý muốn mà kẻ tấn công không giả được (token không đọc nổi, header do chính bạn đặt, xác nhận tường minh). Vì thế câu hỏi đúng không phải "yêu cầu này có đăng nhập chưa" mà là "có gì bảo đảm chính chủ cố ý gửi nó, chứ không phải bị một trang khác chìa tay hộ" — cookie một mình không trả lời được câu đó.

Muốn tự soi trong 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.