SecLab
Client-sideĐầy đủ

CSRF

A01V3CWE-352
01

Là gì

CSRF là lỗi khi một trang web khác làm cho trình duyệt của nạn nhân gửi một request có tác dụng tới ứng dụng của bạn. Trình duyệt tự động kèm cookie vào request đi tới domain đó, nên request tới với đầy đủ phiên đăng nhập của nạn nhân dù nó phát ra từ một trang hoàn toàn khác.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Điều quan trọng nhất về CSRF năm 2026 là: nó gần như đã được vá bởi trình duyệt, và điều đó tạo ra một rủi ro mới — người ta ngừng nghĩ về nó.

Chrome đặt SameSite=Lax làm mặc định cho cookie không khai báo từ 2020, và Firefox/Safari theo sau. Nên một CSRF cổ điển (form POST từ site khác) không còn hoạt động trên phần lớn cấu hình. Nhưng bốn chỗ vẫn hở, và chúng là bốn chỗ đáng đi tìm:

  • SameSite=None vì một lý do nghiệp vụ thật. Widget nhúng, SSO qua iframe, thanh toán redirect — ai đó đặt None cho một cookie và mặc định của trình duyệt biến mất.
  • Lax không bảo vệ GET. Và một endpoint GET có tác dụng (GET /api/account/delete) thì Lax cho đi qua — vì Lax cho phép navigation cấp cao.
  • Cùng site, khác origin. SameSite tính theo site (eTLD+1), không theo origin. evil.example.comapp.example.com là cùng site. Một subdomain bị chiếm, hay một trang user-content trên subdomain, đi qua SameSite=Lax hoàn toàn.
  • API dùng Authorization: Bearer thì không có CSRF — nhưng nếu nó cũng nhận cookie để phục vụ trang web, thì đường cookie vẫn có CSRF.

Và một điều ít ai để ý: CSRF ở endpoint đăng nhập (login CSRF) không cần nạn nhân đã đăng nhập. Kẻ tấn công đăng nhập nạn nhân vào tài khoản của kẻ tấn công, rồi nạn nhân nhập thẻ tín dụng vào đó.

03

Cơ chế hoạt động

Cơ chế là một tính năng của HTTP cookie: cookie đi theo đích, không theo nguồn. Trình duyệt không quan tâm request phát ra từ trang nào.

Nguồn sơ đồ
sequenceDiagram    autonumber    actor V as Nạn nhân (đã đăng nhập app)    participant E as evil.example    participant A as app.example    V->>E: Mở một trang bất kỳ    E-->>V: HTML có form tự submit tới app.example    Note over V: Trình duyệt gửi request tới app.example<br/>và TỰ KÈM cookie phiên — nó không<br/>quan tâm trang nào tạo ra request.    V->>A: POST /api/account/email<br/>Cookie: session=...<br/>email=attacker@evil.example    Note over A: App thấy một request hợp lệ<br/>từ một người dùng đã xác thực.    A-->>V: 200 — email đã đổi    Note over E: Kẻ tấn công dùng "quên mật khẩu"<br/>trên email mới → chiếm tài khoản.

Điểm cốt lõi: kẻ tấn công không đọc được response. Same-origin policy chặn điều đó. Nên CSRF chỉ hữu ích cho hành động có tác dụng phụ — và đó là lý do bản vá chỉ cần áp cho các method thay đổi trạng thái.

Ba giá trị SameSite và chúng chặn được gì:

Giá trịCookie gửi kèm khi nàoCSRF còn hở ở đâu
StrictChỉ khi request phát từ cùng siteGần như không — nhưng người dùng bấm link từ email vào sẽ thấy mình chưa đăng nhập
Lax (mặc định)Cùng site, cộng navigation cấp cao bằng GETGET có tác dụng phụ; và cùng-site-khác-origin
NoneLuôn luôn (bắt buộc kèm Secure)Hở hoàn toàn — cần token CSRF

Và một cửa mà SameSite không đóng: <form> HTML chỉ gửi được Content-Typeapplication/x-www-form-urlencoded, multipart/form-data hoặc text/plain. Một API chỉ nhận application/json thực tế đã có một lớp bảo vệ — vì fetch với Content-Type: application/json cross-origin sẽ bị preflight CORS chặn. Nhưng đừng dựa vào nó một mình: nó vỡ ngay khi có ai bật [FromForm] hay một CORS policy rộng.

Mô tả sơ đồ: Sơ đồ tuần tự: nạn nhân đang đăng nhập app.example mở một trang trên evil.example. Trang đó trả về HTML chứa một form tự submit tới app.example. Trình duyệt gửi request POST đó và tự kèm cookie phiên vào, vì cookie đi theo domain đích chứ không theo trang tạo ra request. App nhận một request trông hoàn toàn hợp lệ từ một người dùng đã xác thực và đổi email của tài khoản. Kẻ tấn công sau đó dùng chức năng quên mật khẩu trên email mới để chiếm tài khoản.

04

Ví dụ cụ thể

Endpoint đổi email. Ba đường tấn công, tương ứng ba lỗ ở khối 2.

html
<!-- ① CSRF cổ điển. Bị SameSite=Lax chặn — trừ khi cookie đặt SameSite=None. --><form id="f" action="https://app.example/api/account/email" method="POST">  <input name="email" value="attacker@evil.example"></form><script>document.getElementById("f").submit()</script>
html
<!-- ② GET có tác dụng phụ. SameSite=Lax CHO ĐI QUA vì đây là navigation cấp cao. --><img src="https://app.example/api/account/delete?confirm=yes"> <!-- Hoặc thẳng hơn: --><script>location = "https://app.example/api/account/delete?confirm=yes"</script>
html
<!-- ③ Cùng site, khác origin. SameSite tính theo eTLD+1, nên trang này --><!-- host trên usercontent.example ĐƯỢC coi là cùng site với app.example. --><!-- Đây là lỗ mà một ô upload HTML hay một subdomain bị chiếm tạo ra. --><script>  fetch("https://app.example/api/account/email", {    method: "POST",    credentials: "include",    headers: { "Content-Type": "application/json" },    body: JSON.stringify({ email: "attacker@evil.example" }),  });</script>
HTTP
# Và sau khi vá: token thiếu → 403, và log ghi lại vì đó là một tín hiệu.POST /api/account/email HTTP/1.1Cookie: session=…; __Host-csrf=abc123(thiếu header X-CSRF-Token) HTTP/1.1 403 Forbidden{"errorCode":"ER_CSRF_TOKEN_MISSING"}
C#Ba lỗ: cookie SameSite=None không có token, GET có tác dụng phụ, và không xác nhận mật khẩu.
// Program.csbuilder.Services.AddAuthentication().AddCookie(o =>{    // ❌ None vì "widget nhúng cần nó". Quyết định có lý do nghiệp vụ thật, nhưng nó    //    xoá mặc định Lax của trình duyệt — và không có token CSRF nào thay vào,    //    nên CSRF cổ điển sống lại nguyên vẹn.    o.Cookie.SameSite = SameSiteMode.None;    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;    o.Cookie.Name = "session";        // ❌ không có tiền tố __Host-}); // ❌ Không có AutoValidateAntiforgeryToken ở global. Bảo vệ theo từng endpoint là//    mặc-định-MỞ, nên endpoint thứ 41 sẽ thiếu. [HttpPost("/api/account/email")]public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest req, CancellationToken ct){    // ❌ Không token CSRF, không kiểm Origin, và không yêu cầu mật khẩu hiện tại.    //    Một form tự submit từ evil.example là đủ để đổi email — rồi "quên mật khẩu"    //    trên địa chỉ mới là account takeover.    var user = await _users.GetAsync(_currentUser.UserId, ct);    user.ChangeEmail(req.Email, DateTime.UtcNow);    await _users.SaveAsync(user, ct);     // ❌ Và xác nhận gửi tới địa chỉ MỚI: kẻ tấn công tự xác nhận thay đổi của mình.    await _mail.SendConfirmationAsync(req.Email, ct);     return Ok();} // ❌ GET có tác dụng phụ. SameSite=Lax CHO ĐI QUA (navigation cấp cao), và mọi//    antiforgery filter đều BỎ QUA GET theo thiết kế — nên không lớp nào bảo vệ nó.//    Endpoint này gần như luôn tồn tại vì một link trong email.[HttpGet("/api/account/delete")]public async Task<IActionResult> Delete([FromQuery] string confirm, CancellationToken ct){    if (confirm == "yes")        await _users.SoftDeleteAsync(_currentUser.UserId, ct);    return Ok();}
TypeScriptDouble-submit không có __Host-: một subdomain đặt được cả hai giá trị.
// Express + double-submit cookieapp.use((req, res, next) => {  if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {    const fromCookie = req.cookies["csrf"];    const fromHeader = req.get("X-CSRF-Token");     // ❌ Phép so ĐÚNG, và bản vá vẫn vô nghĩa. Cookie tên "csrf" không có tiền tố    //    __Host-, nên một subdomain (usercontent.example, hay một subdomain bị chiếm)    //    đặt được cookie đó với Domain=.example — rồi nó biết giá trị và copy sang    //    header. Hai bên khớp, và request đi qua.    if (!fromCookie || fromCookie !== fromHeader) {      return res.status(403).json({ error: "csrf" });    }  }  next();});
05

Chuyện đã xảy ra

Netflix, 2006 (Dave Ferguson). Một chuỗi CSRF cho phép thêm phim vào danh sách thuê của nạn nhân, đổi địa chỉ giao hàng, và đổi cả email + mật khẩu — tức là chiếm tài khoản hoàn toàn. Đáng đọc vì nó là ví dụ rõ nhất cho thấy CSRF không phải một lỗi "nhẹ": chuỗi kết thúc bằng account takeover, và không có một dòng JavaScript nào chạy trên origin của Netflix.

ING Direct, 2008. CSRF cho phép chuyển tiền giữa các tài khoản của nạn nhân và tạo tài khoản mới. Được nêu trong nghiên cứu của Zeller & Felten (Princeton) cùng với các trường hợp ở YouTube, MetaFilter và The New York Times. Bài học của bài báo đó vẫn đúng: CSRF là lỗi của việc thiếu một thứ, nên không có gì trong code để tìm.

Và ghi chú về hiện tại: từ 2020 số sự cố CSRF lớn giảm mạnh, và nguyên nhân là mặc định SameSite=Lax của trình duyệt — không phải vì các ứng dụng đã vá. Đây là lý do khối 2 nhấn mạnh bốn chỗ còn hở: chúng là những chỗ mà mặc định của trình duyệt không tới được.

06

Cách phòng chống

Lớp 1

Cookie đúng thuộc tính — đây là bản vá rẻ nhất và nó đóng phần lớn bề mặt

bắt buộc

Một dòng cấu hình, và nó làm CSRF cổ điển không hoạt động:

C#
options.Cookie.SameSite = SameSiteMode.Lax;   // hoặc Strictoptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;options.Cookie.HttpOnly = true;options.Cookie.Name = "__Host-session";        // xem bên dưới

Strict hay Lax là một quyết định về sản phẩm, không về bảo mật: Strict chặn nhiều hơn nhưng người dùng bấm link từ email vào sẽ thấy mình chưa đăng nhập. Mẫu dùng được: Strict cho cookie phiên, cộng một cookie thứ hai Lax chỉ để nhận diện "người này từng đăng nhập" và hiện màn hình chào thay vì trang đăng nhập trống.

Tiền tố __Host- là phần hay bị bỏ và nó quan trọng hơn vẻ ngoài. Nó buộc cookie phải có Secure, Path=/, và không có Domain — nghĩa là một subdomain không thể ghi đè cookie đó. Đây chính là biện pháp đóng lỗ ③ ở khối 2 (cùng site, khác origin): không có __Host-, một subdomain bị chiếm đặt được cookie CSRF của riêng nó và vòng qua double-submit.

C# · Layer 1__Host- prefix, antiforgery global, GET không tác dụng phụ, và mật khẩu cho hành động nhạy cảm.
// ── Program.cs ──────────────────────────────────────────────────────────────builder.Services.AddAuthentication().AddCookie(o =>{    // Lax là mặc định của trình duyệt; viết ra để nó không phụ thuộc vào trình duyệt    // của người dùng và để người đọc sau biết đây là lựa chọn có ý thức.    o.Cookie.SameSite = SameSiteMode.Lax;    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;    o.Cookie.HttpOnly = true;     // Tiền tố __Host- là phần hay bị bỏ và nó quan trọng hơn vẻ ngoài: nó BUỘC cookie    // phải có Secure, Path=/, và KHÔNG có Domain — nghĩa là một subdomain không thể    // ghi đè nó. Đây chính là biện pháp đóng lỗ "cùng site, khác origin", thứ mà    // SameSite không đóng được vì SameSite tính theo eTLD+1.    o.Cookie.Name = "__Host-session";}); builder.Services.AddAntiforgery(o =>{    o.HeaderName = "X-CSRF-Token";              // SPA copy từ cookie sang header    o.Cookie.Name = "__Host-csrf";              // cùng lý do như trên    o.Cookie.SecurePolicy = CookieSecurePolicy.Always;    // HttpOnly = false CÓ CHỦ Ý và là ngoại lệ duy nhất: client phải đọc được token    // này để copy sang header. Nó không phải secret dài hạn — giá trị của nó nằm ở    // chỗ một trang KHÁC ORIGIN không đọc được cookie của ta để copy sang.    o.Cookie.HttpOnly = false;}); // Mặc-định-ĐÓNG. Auto bật cho MỌI method không an toàn và tự bỏ qua GET/HEAD, nên// một controller mới thêm vào được bảo vệ mà không cần ai nhớ. Chiều này là điểm// chính: bảo vệ theo từng endpoint là mặc-định-MỞ.builder.Services.AddControllers(o => o.Filters.Add(new AutoValidateAntiforgeryTokenAttribute())); // ── Lớp 2 · Sec-Fetch-Site ─────────────────────────────────────────────────/// <summary>/// Bắt cái lớp 1 sẽ bỏ sót: một endpoint mới không đi qua filter, hoặc cấu hình/// cookie bị đổi trong một lần deploy.////// Sec-Fetch-Site do TRÌNH DUYỆT đặt và JavaScript không sửa được — nên nó không dựa/// vào việc ứng dụng nói gì. Nhưng nó KHÔNG thay được token: một subdomain bị chiếm/// gửi tới đúng giá trị "same-site"./// </summary>public sealed class FetchMetadataMiddleware(RequestDelegate next, ILogger<FetchMetadataMiddleware> log){    private static readonly string[] Unsafe = ["POST", "PUT", "PATCH", "DELETE"];     public async Task InvokeAsync(HttpContext ctx)    {        if (Unsafe.Contains(ctx.Request.Method))        {            var site = ctx.Request.Headers["Sec-Fetch-Site"].FirstOrDefault();             // Chặn khi header CÓ và LÀ cross-site. Thiếu header thì KHÔNG chặn —            // trình duyệt cũ không gửi nó, và fail-closed ở đây chặn người dùng thật.            // Thiếu thì rơi về token CSRF ở lớp 1, và đó là lý do lớp 1 phải có.            if (site == "cross-site")            {                // Tín hiệu độ nhiễu rất thấp: client của ta không bao giờ tạo ra nó.                log.LogWarning("Chặn request cross-site tới {Path} từ {Origin}",                    ctx.Request.Path, ctx.Request.Headers.Origin.FirstOrDefault());                ctx.Response.StatusCode = 403;                return;            }        }         await next(ctx);    }} // ── Endpoint ────────────────────────────────────────────────────────────────[HttpPost("/api/account/email")]public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest req, CancellationToken ct){    var user = await _users.GetAsync(_currentUser.UserId, ct)        ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND);     // Lớp 3 — mật khẩu hiện tại. Kẻ tấn công không có nó, nên chuỗi Netflix ở khối 5    // dừng ngay tại bước đầu KỂ CẢ khi cả hai lớp trên bị vòng qua.    if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword))        throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD);     // Không đổi ngay: ghi một yêu cầu chờ, và xác nhận đi tới địa chỉ CŨ.    // Gửi tới địa chỉ mới thì kẻ tấn công tự xác nhận thay đổi của mình — chi tiết    // này quyết định bản vá có nghĩa hay không.    var request = user.RequestEmailChange(req.Email, DateTime.UtcNow);    await _users.SaveAsync(user, ct);    await _mail.SendEmailChangeNoticeAsync(user.Email, request.Token, ct);     return Accepted();} /// <summary>/// GET không có tác dụng phụ — nó render một trang CÓ NÚT, và nút gửi POST.////// Vì sao phải thế: Lax cho GET đi qua khi đó là navigation cấp cao, và mọi/// antiforgery filter đều BỎ QUA GET theo thiết kế. Một GET có tác dụng phụ là một/// endpoint không lớp nào bảo vệ./// </summary>[HttpGet("/account/delete")]public IActionResult DeleteConfirmationPage() => View(); [HttpDelete("/api/account")]public async Task<IActionResult> Delete([FromBody] ConfirmRequest req, CancellationToken ct){    var user = await _users.GetAsync(_currentUser.UserId, ct)        ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND);     if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword))        throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD);     await _users.SoftDeleteAsync(user.Id, ct);    return NoContent();}
TypeScript · Layer 1__Host- prefix, so hằng thời gian, và mặc-định-đóng cho mọi method không an toàn.
import { randomBytes, timingSafeEqual } from "node:crypto"; const UNSAFE = new Set(["POST", "PUT", "PATCH", "DELETE"]); // Tiền tố __Host- là phần làm double-submit thật sự an toàn: nó BUỘC Secure, Path=/,// và KHÔNG Domain — nên một subdomain không thể đặt hay ghi đè cookie này. Không có// nó, double-submit chỉ là một phép so mà kẻ tấn công kiểm soát cả hai phía.const CSRF_COOKIE = "__Host-csrf";const CSRF_HEADER = "x-csrf-token"; function issueToken(res: Response): string {  const token = randomBytes(32).toString("base64url");  res.cookie(CSRF_COOKIE, token, {    secure: true,      // __Host- yêu cầu    path: "/",         // __Host- yêu cầu    sameSite: "lax",    // httpOnly: false CÓ CHỦ Ý — client phải đọc được để copy sang header. Giá trị    // của cơ chế nằm ở chỗ một trang KHÁC ORIGIN không đọc được cookie này.    httpOnly: false,  });  return token;} function safeEqual(a: string, b: string): boolean {  const ba = Buffer.from(a), bb = Buffer.from(b);  // Độ dài khác nhau thì timingSafeEqual ném lỗi, nên kiểm trước. Và so hằng thời  // gian vì một phép so thoát sớm rò từng byte của token qua thời gian phản hồi.  return ba.length === bb.length && timingSafeEqual(ba, bb);} app.use((req, res, next) => {  // Mặc-định-ĐÓNG: mọi method không an toàn đều phải qua, không có allowlist endpoint.  if (!UNSAFE.has(req.method)) return next();   // Lớp 2 — Sec-Fetch-Site. Chặn khi CÓ và LÀ cross-site; thiếu thì rơi về token.  if (req.get("sec-fetch-site") === "cross-site") {    req.log.warn({ origin: req.get("origin"), path: req.path }, "chặn request cross-site");    return res.status(403).json({ error: "cross_site" });  }   const fromCookie = req.cookies[CSRF_COOKIE];  const fromHeader = req.get(CSRF_HEADER);   if (!fromCookie || !fromHeader || !safeEqual(fromCookie, fromHeader)) {    // Tín hiệu độ nhiễu rất thấp: client của ta không bao giờ tạo ra nó.    req.log.warn({ origin: req.get("origin"), path: req.path }, "csrf token không khớp");    return res.status(403).json({ error: "csrf" });  }   next();});
Lớp 1b

Token CSRF cho mọi request thay đổi trạng thái

bắt buộc

Bản vá không phụ thuộc vào mặc định của trình duyệt. Cần nó vì bốn lỗ ở khối 2, và đặc biệt cần khi cookie phải là SameSite=None.

Dùng cơ chế của framework, không tự viết. ASP.NET Core có antiforgery sẵn: [ValidateAntiForgeryToken], hoặc [AutoValidateAntiforgeryToken] ở mức global filter — chọn bản Auto vì nó mặc định bật cho mọi method không an toàn và tự bỏ qua GET/HEAD. Chiều mặc định đó là điểm chính: một controller mới thêm vào phải được bảo vệ mà không cần ai nhớ.

Hai mẫu, và mẫu thứ hai chỉ dùng khi không có state ở server:

  • Synchronizer token: token sinh ở server, gắn với phiên, so ở server. An toàn nhất.
  • Double-submit cookie: token nằm trong cookie trong header; server so hai cái. Không cần state, nhưng nó chỉ an toàn khi cookie có tiền tố __Host- — nếu không, một subdomain đặt được cả hai giá trị và phép so trở thành vô nghĩa.

Với SPA: token vào một cookie đọc được bằng JS (không HttpOnly, đây là ngoại lệ có chủ ý vì token này không phải secret dài hạn) rồi client copy nó vào header X-CSRF-Token. Cách này hoạt động vì một trang khác origin không đọc được cookie của bạn để copy sang header.

Lớp 1c

Không có endpoint GET nào thay đổi trạng thái

bắt buộc

Bản vá cho lỗ ② ở khối 2, và nó vừa là luật HTTP vừa là luật bảo mật. Lax cho GET đi qua khi đó là navigation cấp cao, và cả antiforgery filter lẫn AutoValidateAntiforgeryToken đều bỏ qua GET theo thiết kế — nên một GET có tác dụng phụ không được bảo vệ bởi bất kỳ lớp nào.

Luật: GETHEAD phải an toàn theo nghĩa của RFC 9110 — không tác dụng phụ nào. Xoá thì DELETE, đổi thì PATCH/PUT, tạo thì POST.

Phép kiểm rẻ và bắt được nó: grep tên action GET có động từ hành động (Delete, Remove, Cancel, Approve, Confirm, Reset, Send) — xem khối 7. Trong thực tế, những endpoint này gần như luôn xuất hiện vì một link trong email ("bấm vào đây để xác nhận") — và đó là lý do đáng có một endpoint GET render một trang có nút, rồi nút đó gửi POST.

Lớp 2

Kiểm `Origin`/`Sec-Fetch-Site` như lớp thứ hai

Lớp này bắt đúng cái lớp 1 sẽ bỏ sót: một endpoint mới không đi qua filter, hoặc một cấu hình cookie bị đổi trong một lần deploy.

  • Sec-Fetch-Site là header do trình duyệt đặt và JavaScript không sửa được. same-origin hoặc same-site là hợp lệ; cross-site thì từ chối. Đây là phép kiểm mạnh nhất trong lớp này vì nó không dựa vào ứng dụng nói gì.
  • Origin có ở mọi request POST cross-origin (và ở cả same-origin trong các trình duyệt hiện tại). So nó với allowlist host của bạn.

Hai điều phải cẩn thận, nếu không lớp này thành một lỗ:

  • Header thiếu thì xử lý thế nào? Với Sec-Fetch-Site, trình duyệt cũ không gửi — nên fail-closed sẽ chặn người dùng thật. Mẫu đúng: chặn khi header cross-site; thiếu thì rơi về token CSRF ở lớp 1.
  • Không dùng nó thay token. Đây là lớp 2 vì nó không đóng được lỗ ③ (cùng site, khác origin) — Sec-Fetch-Site: same-site là giá trị mà một subnhánh bị chiếm gửi tới.
Lớp 3

Xác nhận lại cho hành động nhạy cảm, và token thiếu là một tín hiệu

CSRF chỉ mạnh khi hành động có tác dụng ngay và một lần. Với hành động nhạy cảm nhất, phá điều đó là biện pháp hiệu quả nhất và nó không phụ thuộc vào bất kỳ lớp nào ở trên:

  • Yêu cầu mật khẩu hiện tại để đổi email, đổi mật khẩu, thêm khoá API, tắt MFA. Kẻ tấn công không có mật khẩu, nên chuỗi Netflix ở khối 5 dừng ngay tại bước đầu.
  • Xác nhận qua email cho đổi email: gửi link tới địa chỉ , không địa chỉ mới. Đây là chi tiết quyết định — gửi tới địa chỉ mới thì kẻ tấn công tự xác nhận.
  • Step-up MFA cho chuyển tiền, xoá tài khoản.

Và về phát hiện: một request thiếu token CSRF hoặc có Origin lạ là tín hiệu độ nhiễu rất thấp — client của bạn không bao giờ tạo ra nó. Nên đừng chỉ trả 403 im lặng: log nó kèm OriginReferer, và alert khi tốc độ tăng. Đây là lớp 3 vì nó không chặn gì, nhưng với một họ lỗi mà nạn nhân không hề biết mình bị tấn công, đó là thứ duy nhất nói cho bạn biết.

07

Kiểm chứng đã vá

1. Test đơn vị: mọi method không an toàn PHẢI từ chối khi thiếu token. Và quan trọng hơn: một test đếm — liệt kê mọi endpoint POST/PUT/PATCH/DELETE từ EndpointDataSource và khẳng định từng cái có antiforgery. Endpoint mới không có thì fail build. Cùng kỹ thuật với AuthzCoverage ở topic access-control. Xem tab csharp / test.

2. Grep chặn merge cho GET có tác dụng phụ (lỗ ②) — phép kiểm rẻ nhất ở đây:

Shell
grep -rnE '\[HttpGet' -A3 --include='*.cs' src/ \  | grep -iE 'public .*(Delete|Remove|Cancel|Approve|Confirm|Reset|Send|Revoke|Disable)' \  && { echo "GET có tác dụng phụ — không lớp nào bảo vệ nó"; exit 1; }exit 0

3. Kiểm thuộc tính cookie trên response thật — cấu hình lớp 1 sống trong Program.cs và nó bị đổi trong một lần deploy mà không test nào đỏ:

Shell
S=$(curl -si -X POST https://staging.example.com/api/auth/login \      -d '{"email":"test@x.com","password":"…"}' -H 'Content-Type: application/json' \    | grep -i '^set-cookie:' | tr -d '\r')echo "$S" | grep -qi 'samesite='  || { echo "cookie thiếu SameSite"; exit 1; }echo "$S" | grep -qi 'httponly'   || { echo "cookie phiên thiếu HttpOnly"; exit 1; }echo "$S" | grep -qi 'secure'     || { echo "cookie thiếu Secure"; exit 1; }# Và nếu dùng double-submit thì cookie CSRF PHẢI có tiền tố __Host-echo "$S" | grep -qi 'csrf' && echo "$S" | grep -q '__Host-' \  || echo "CẢNH BÁO: cookie CSRF không có __Host- — double-submit vòng qua được từ subdomain"

4. Kiểm end-to-end bằng một request cross-origin thật. Đây là phép kiểm duy nhất mô phỏng đúng điều kiện: dựng một trang trên origin khác trong Playwright, đăng nhập vào app ở tab đó, rồi submit form cross-origin và khẳng định trạng thái không đổi. Test server-side không bắt được lỗ ② vì nó không có trình duyệt để áp SameSite.

5. Kiểm hành động nhạy cảm thật sự yêu cầu mật khẩu (lớp 3):

Shell
# Đổi email KHÔNG kèm currentPassword phải là 4xxcurl -s -o /dev/null -w '%{http_code}\n' -X POST "$B/api/account/email" \  -b "$COOKIES" -H "X-CSRF-Token: $T" -H 'Content-Type: application/json' \  -d '{"email":"new@x.com"}'    # phải là 422, không phải 200
C#Test ĐẾM: endpoint không an toàn nào thiếu antiforgery thì fail build.
public class CsrfTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public CsrfTests(ApiFixture fx) => _fx = fx;     /// <summary>    /// Phép kiểm quan trọng nhất của topic này, và nó là một phép ĐẾM chứ không phải    /// một phép thử: liệt kê MỌI endpoint không an toàn và khẳng định từng cái có    /// antiforgery. Một endpoint mới thêm tuần sau tự động nằm trong phạm vi.    ///    /// Cùng kỹ thuật với AuthzCoverage ở topic access-control, và cùng lý do: CSRF là    /// lỗi của việc THIẾU một thứ, nên code review không thấy nó — không ai nhận ra    /// một attribute không có mặt trong diff.    /// </summary>    [Fact]    public void Every_unsafe_endpoint_has_antiforgery()    {        string[] unsafeMethods = ["POST", "PUT", "PATCH", "DELETE"];         var unprotected = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints            .OfType<RouteEndpoint>()            .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods                         .Any(unsafeMethods.Contains) == true)            // Endpoint dùng bearer token thì không có CSRF — trình duyệt không tự gửi            // Authorization header. Miễn tường minh, và danh sách miễn phải ngắn.            .Where(e => e.Metadata.GetMetadata<BearerOnlyAttribute>() is null)            .Where(e => e.Metadata.GetMetadata<IAntiforgeryMetadata>() is null or { RequiresValidation: false })            .Select(e => e.RoutePattern.RawText)            .ToList();         Assert.Empty(unprotected);    }     /// <summary>    /// GET phải AN TOÀN theo nghĩa RFC 9110. Test này bắt lỗ mà không lớp nào bảo vệ:    /// Lax cho GET đi qua, và antiforgery bỏ qua GET theo thiết kế.    /// </summary>    [Fact]    public void No_get_endpoint_has_a_side_effecting_name()    {        string[] verbs = ["Delete", "Remove", "Cancel", "Approve", "Confirm", "Reset", "Send", "Revoke", "Disable"];         var suspicious = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints            .OfType<RouteEndpoint>()            .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") == true)            .Where(e => verbs.Any(v => e.DisplayName?.Contains(v, StringComparison.OrdinalIgnoreCase) == true))            .Select(e => e.DisplayName)            .ToList();         Assert.Empty(suspicious);    }     [Fact]    public async Task Post_without_csrf_token_is_rejected()    {        var client = await _fx.SignedInClientWithoutCsrfAsync(_fx.Bob);         var res = await client.PostAsJsonAsync("/api/account/email",            new { email = "attacker@evil.example", currentPassword = "correct-horse" });         Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);         // Khẳng định ở TẦNG DỮ LIỆU: một 400 sau khi đã ghi vẫn pass khẳng định về        // status code, và đó là loại bản vá nửa vời cần bắt.        Assert.Equal(_fx.Bob.Email, (await _fx.GetUserFromDbAsync(_fx.Bob.Id)).Email);    }     /// <summary>Lớp 2 — Sec-Fetch-Site: cross-site bị chặn dù token có hợp lệ.</summary>    [Fact]    public async Task Cross_site_request_is_rejected_even_with_a_valid_token()    {        var (client, token) = await _fx.SignedInClientWithCsrfAsync(_fx.Bob);        var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/email")        {            Content = JsonContent.Create(new { email = "attacker@evil.example", currentPassword = "correct-horse" }),        };        req.Headers.Add("X-CSRF-Token", token);        req.Headers.Add("Sec-Fetch-Site", "cross-site");         Assert.Equal(HttpStatusCode.Forbidden, (await client.SendAsync(req)).StatusCode);    }     /// <summary>Thiếu header thì KHÔNG chặn — trình duyệt cũ không gửi nó.</summary>    [Fact]    public async Task Missing_fetch_metadata_falls_back_to_the_token()    {        var (client, token) = await _fx.SignedInClientWithCsrfAsync(_fx.Bob);        var req = new HttpRequestMessage(HttpMethod.Post, "/api/account/email")        {            Content = JsonContent.Create(new { email = "new@acme.com", currentPassword = "correct-horse" }),        };        req.Headers.Add("X-CSRF-Token", token);   // không có Sec-Fetch-Site         Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(req)).StatusCode);    }     /// <summary>    /// Cấu hình cookie sống trong Program.cs, nên nó bị đổi trong một lần deploy mà    /// không test nào đỏ. Test này là thứ làm nó đỏ — kể cả tiền tố __Host-, thứ mà    /// không có nó thì double-submit vòng qua được từ một subdomain.    /// </summary>    [Fact]    public async Task Session_and_csrf_cookies_have_the_required_attributes()    {        var res = await _fx.Client.PostAsJsonAsync("/api/auth/login",            new { email = _fx.Bob.Email, password = "correct-horse" });         var cookies = res.Headers.GetValues("Set-Cookie").ToList();         var session = Assert.Single(cookies, c => c.Contains("session"));        Assert.Contains("__Host-", session);        Assert.Contains("HttpOnly", session, StringComparison.OrdinalIgnoreCase);        Assert.Contains("Secure", session, StringComparison.OrdinalIgnoreCase);        Assert.Contains("SameSite=Lax", session, StringComparison.OrdinalIgnoreCase);         var csrf = Assert.Single(cookies, c => c.Contains("csrf"));        Assert.Contains("__Host-", csrf);        // Và cookie CSRF cố ý KHÔNG HttpOnly — client phải đọc được để copy sang header.        Assert.DoesNotContain("HttpOnly", csrf, StringComparison.OrdinalIgnoreCase);    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Kiểm RefererVắng mặt trong nhiều cấu hình privacy, và Referrer-Policy: no-referrer của chính bạn xoá nó. Dùng Origin hoặc Sec-Fetch-Site
Token CSRF trong cookie thôiTrình duyệt tự gửi cookie, nên nó tới cả trong request cross-site. Token phải ở header hoặc body — chỗ mà chỉ code cùng origin đặt được
Double-submit mà cookie không có __Host-Một subdomain đặt được cả cookie lẫn header. Phép so vẫn khớp, và bản vá vô nghĩa
SameSite=Lax là đủKhông bảo vệ GET có tác dụng phụ, và không bảo vệ cùng-site-khác-origin
SameSite=None "để widget hoạt động" rồi quên tokenĐây là chỗ CSRF cổ điển sống lại nguyên vẹn
Bảo vệ theo endpoint, thêm attribute từng cáiEndpoint thứ 41 sẽ thiếu. AutoValidateAntiforgeryToken ở global là mặc-định-đóng
Dựa vào việc API chỉ nhận JSONĐúng cho form HTML, nhưng vỡ ngay khi có ai thêm [FromForm] hay một CORS policy rộng. Nó là hệ quả, không phải bản vá
Token dùng chung cho mọi phiênKẻ tấn công lấy token của chính họ rồi dùng cho nạn nhân. Token phải gắn với phiên
Đổi email rồi gửi xác nhận tới địa chỉ MỚIKẻ tấn công tự xác nhận. Phải gửi tới địa chỉ cũ

Sai lầm về thời đại, và nó là sai lầm chính của topic này năm 2026: "trình duyệt đã lo SameSite rồi". Đúng cho cấu hình mặc định, và bốn lỗ ở khối 2 là chính xác những chỗ mà mặc định không tới. Cộng thêm một điểm: bạn không kiểm soát trình duyệt của người dùng.

Sai lầm về mức độ: coi CSRF là "medium" vì kẻ tấn công không đọc được response. Chuỗi Netflix ở khối 5 kết thúc bằng account takeover mà không đọc một response nào — đổi email rồi "quên mật khẩu" là đủ.

09

Nguồn tham khảo

Bậc 1CWE-352: Cross-Site Request Forgery (CSRF) · MITRE · CWE · 4.14
Bậc 1RFC 6265bis — Cookies: SameSite and cookie name prefixes · IETF · Internet-Draft · draft-ietf-httpbis-rfc6265bis
Bậc 1Prevent Cross-Site Request Forgery attacks in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Bậc 1Sec-Fetch-Site · MDN · HTTP headers reference · 2025
Bậc 2Cross-site request forgery (CSRF) · PortSwigger · Web Security Academy
Bậc 2Cross-Site Request Forgery Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 3Cross-Site Request Forgeries: Exploitation and Prevention · William Zeller & Edward W. Felten, Princeton University
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…