CSRF 到底是什么:ASP.NET Core JSON 接口与防伪误拦截复盘

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
CSRFASP.NET CoreCookieCORS故障复盘

一个正常的小游戏下单请求,向 ASP.NET Core Controller 发送 application/json,携带有效 Bearer Token,却直接得到空响应的 400 Bad Request。相邻的 GET 接口正常,POST 在约两毫秒内结束,订单方法里的第一条业务日志也没有出现。

最后查到,拦截来自之前加入的一套全局防伪机制。过滤器只要发现请求已经认证,就对 POST 等写请求调用 ValidateRequestAsync,没有区分 Cookie 和 Bearer,也没有先证明这类 JSON 接口需要额外的防伪令牌。

故障之前,前端还为这套机制增加了页面初始化请求、令牌缓存、登录后刷新、并发复用等逻辑。我曾质疑为什么打开页面就要请求一次防伪令牌,得到的解释却一直建立在“这套机制必须存在”的前提上。

排查到这里,我需要重新确认:攻击页面能发什么请求,浏览器会带上什么凭据,控制器能否接受这些输入。这些问题没弄清,前端再怎么优化令牌获取,也解释不了为什么正常下单会被拦住。

本文于 2026 年 11 月 3 日核对,实测运行时为 .NET 10.0.14,框架源码链接固定到 ASP.NET Core v10.0.0。微软文档中还有标为 .NET 11 的自动保护说明,下文会说明它与这次故障所用机制的区别。

这个 JSON 接口需要防伪令牌吗

对于默认 JSON 输入格式化器下的 [FromBody] DTO 接口,普通跨站 HTML 表单不能直接提供它接受的输入。其他网站的脚本如果发送 application/json,又会遇到浏览器的跨域预检;使用受害者 Cookie 的请求还要满足带凭据跨域请求的条件。

因此,不能仅凭“使用 Cookie 登录”和“这是 POST”就认定接口必须增加一套 CSRF Token 流程。需要先确认完整的攻击路径是否成立。

这次出错的客户端主动设置 Authorization: Bearer ...,并不依赖浏览器自动附带身份凭据。Token 校验成功后,过滤器却要求它再提供防伪 Cookie,把 Bearer 认证也纳入了校验范围。

JSON 接口仍然可能存在越权、凭据泄露或重复扣款等问题,只是这些问题需要分别处理。

CSRF 借用的是浏览器自动携带的身份

CSRF,即跨站请求伪造。典型过程是:用户已经登录目标网站,又打开攻击者的网站;攻击页面诱导浏览器向目标网站执行某个操作,浏览器在条件允许时自动附带目标网站的身份凭据,目标接口把这个请求当成用户自己的操作。

攻击者不一定知道 Cookie 的内容,也不一定能读取响应。攻击的目标可以只是让某个状态修改成功。微软的CSRF 文档引言使用的正是跨站表单提交示例。

一次攻击要成立,需要同时满足以下条件:

  1. 攻击者能诱导受害者浏览器发起请求。
  2. 浏览器允许发出目标接口能够接受的请求。
  3. 请求会附带受害者在目标网站的有效身份凭据。
  4. 目标接口会据此完成攻击者希望触发的操作。

只接受 JSON DTO 的控制器能否满足这些条件,需要单独验证,表单提交示例不能直接说明它也存在同样的问题。

这里说的凭据也不只限于 Cookie;关键是浏览器是否会自动附带。反过来,JWT 只是令牌的格式,把 JWT 放在 Cookie 里,和由客户端显式放进 Bearer 请求头,是两种不同的传递方式。

要检查的是服务器接受什么输入

自己的网页没有 <form>,攻击者的页面也可以有。排查时仍要检查服务器是否接受表单输入。

假设控制器声明如下:

[ApiController]
public class OrdersController : ControllerBase
{
    [HttpPost("/api/orders")]
    public IActionResult Create([FromBody] CreateOrderRequest request)
    {
        return Ok(new { request.Amount });
    }
}

public record CreateOrderRequest(int Amount);

在 ASP.NET Core 中,[FromBody] 表示使用输入格式化器读取请求体,不是让框架依次尝试表单、查询参数和 JSON。BodyModelBinder 源码会选择能读取当前请求的格式化器,找不到时记录不支持的内容类型错误。

默认 MVC 配置注册的是 JSON 输入格式化器,并注册了处理不支持内容类型的过滤器,见默认 MVC 配置源码。这与 [FromForm] 表单绑定是不同的入口。

默认的 SystemTextJsonInputFormatter支持 application/json、text/json 和 application/*+json,不会因为 text/plain 的请求体看起来像 JSON 就接受它。支持范围并不限于字面上的 application/json,但另外两种 JSON 媒体类型也不在 CORS 安全列表中,普通 HTML 表单无法使用。

所以,面对上述控制器,不能把下面几种输入当成等价物:

application/json
{"amount":1}

application/x-www-form-urlencoded
amount=1

text/plain
{"amount":1}

本文后面的实测中,第一种返回 200,后两种返回 415;格式正确的 multipart/form-data 表单同样返回 415。

[FromBody] 的接受范围取决于已注册的输入格式化器。如果应用额外注册了 XML 或自定义格式化器,就要按实际配置重新检查。微软的自定义输入格式化器文档也区分了内置支持和额外注册的格式。

浏览器能发出哪些请求

表单可以跨站发送,不代表 JSON 控制器会处理

普通 HTML 表单能够使用的 POST 编码包括 application/x-www-form-urlencoded、multipart/form-data 和 text/plain。这些也是讨论跨域简单请求时经常出现的内容类型。

这解释了传统 CSRF 为什么经常使用表单:浏览器不需要先取得目标服务器的 CORS 许可,就能发送某些请求。但如果目标端点通过默认 [FromBody] JSON 格式化器拒绝这些输入,攻击链就停在了输入处理这一环。MDN 的 CORS 文档列出了简单请求的限制。

因此,还要检查到达服务器的请求能否通过输入绑定、执行到业务代码。

跨域 JSON 由预检决定是否发送实际请求

攻击页面如果改用脚本:

fetch("https://api.example.test/api/orders", {
  method: "POST",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ amount: 1 })
});

浏览器会先通过 OPTIONS 预检确认是否允许这类跨域 JSON 请求。预检未通过带凭据请求所需的检查,实际 POST 就不会发出。预检结果可以缓存,所以网络面板中不一定每次都有新的 OPTIONS,具体过程见 MDN 的预检说明。

假设公共 API 返回的是:

Access-Control-Allow-Origin: *

却没有允许跨域凭据,那么其他网站就不能凭这项配置借用受害者的 Cookie 完成上述 JSON 操作。允许匿名跨域调用,并不包含使用登录 Cookie 的权限。

不需要预检的请求则是另一种情况:CORS 不会统一在服务器执行前拦住它们,Cookie 是否发送还取决于请求方式和 Cookie 属性。因此,缺少 AllowCredentials() 不能保证所有跨站请求都不带 Cookie。

Cookie 还受主机、路径、Secure、SameSite 等属性限制。HttpOnly 限制脚本读取 Cookie,不等于禁止浏览器携带它;显式设置的 SameSite=Lax 通常会阻止跨站 POST 附带 Cookie,但允许某些顶层安全方法导航。MDN 的 Set-Cookie 属性说明给出了这些属性的边界。

还要区分“同源”和“同站”。同源比较协议、主机和端口;同站的范围不同,两个不同子域可能同站却不同源。不能把 SameSite 理解成只允许自己同一个 origin。

排查具体请求时,这些属性要与前面的媒体类型、预检结果和服务端绑定方式一起检查。

同源执行需要额外前提

把目标地址写成自己网站的地址,不会把攻击页面变成同源页面。运行在 https://other.example 的脚本,向 https://api.example.test 请求数据,仍然是跨源请求。

要在目标网站的同源页面中执行脚本,攻击者还得有脚本注入、篡改受信任资源等条件。这些条件需要另外举证,不能在讨论跨站请求时默认攻击者已经具备。

同样,服务端程序或 curl 可以自行设置一些 HTTP 请求头,但它们没有受害者浏览器中保存的凭据。伪造一个请求头不会让目标服务器把另一个人浏览器里的 Cookie 自动搬到攻击者的程序里。

如果攻击者已经取得有效登录凭据,就应按凭据泄露和会话滥用处理。

ASP.NET Core 的防伪校验在哪里启用

排查中还出现过“微软官方对 JSON 都直接跳过”的解释。核对源码后可以看到,普通 JSON 控制器默认没有令牌校验,但显式启用校验后,JSON 请求同样可能因缺少令牌被拒绝。

AddControllers 不等于启用了防伪校验

微软在Antiforgery with AddControllers中说明了 AddControllers 与防伪支持的关系。一个仅绑定 JSON 的普通控制器,不会因为用户已认证或者使用 POST,就自动获得全局令牌校验。

AddAntiforgery() 只负责提供令牌服务。请求是否接受校验,还要看过滤器、中间件或业务代码是否调用了它;容器中注册了 IAntiforgery 并不足以说明所有接口都在校验。

.NET 10 的显式 MVC 令牌校验不会因 JSON 自动跳过

ASP.NET Core v10.0.0 的 AutoValidateAntiforgeryTokenAuthorizationFilter 源码按 HTTP 方法决定是否校验:GET、HEAD、TRACE、OPTIONS 跳过,其余方法进入校验。它没有“Content-Type 是 JSON 就跳过”的条件。

其父类ValidateAntiforgeryTokenAuthorizationFilter会调用 ValidateRequestAsync,捕获防伪校验异常并设置失败结果。也就是说,主动给 JSON 接口套上这个过滤器,缺少令牌就可能得到 400。这只能证明配置要求令牌,不能证明该接口在没有额外令牌时一定可被 CSRF 利用。

本文事故中的自定义过滤器走的就是这类主动校验路径。

只在表单类型时读令牌,不等于非表单直接通过校验

查看 DefaultAntiforgeryTokenStore 源码,可以看到它先读取配置的请求头;请求头没有令牌并且 HasFormContentType 为真时,才尝试读表单字段。

HasFormContentType 在这里决定是否从表单取令牌。JSON 请求跳过表单读取后,仍会进入后续校验;缺少防伪 Cookie 或请求令牌时,DefaultAntiforgery.ValidateRequestAsync会抛出异常。

端点中间件与 MVC 过滤器也不同

在 v10.0.0 的AntiforgeryMiddleware 源码中,是否执行令牌校验还取决于端点是否带有要求校验的元数据。它不是扫描所有 POST JSON 后逐一要求令牌。

检查 UseAntiforgery()、Minimal API 表单绑定或 MVC 控制器属性时,要分别确认它们的启用条件。

当前文档新增的自动保护,另有版本边界

核对时,微软页面还包含依据 Sec-Fetch-Site、Origin 判断请求来源的自动 CSRF 保护。页面的两种机制对比把这套自动中间件标为 .NET 11 引入,尽管访问链接带有 view=aspnetcore-10.0。

其延迟校验说明明确区分了记录判断结果和由消费组件执行拒绝:不读取表单、只从 JSON 绑定请求体的端点,不会仅因这套自动机制记录了无效结果,就自动被拒绝。

这段说明针对的是新版自动保护,.NET 10 显式 MVC 令牌过滤器仍按前面的逻辑执行。核对既有项目时,要确认运行时和校验入口,并对照固定版本源码;URL 中的版本参数未必足以区分页面里的全部内容。

这次故障是怎样一步步引入的

2026 年 10 月 23 日,管理端从 JWT 改为 Cookie 登录。AI 在实施时主动补上登录、退出以及已登录管理写请求的防伪校验。

当时的解释是“没有注册全局自动防伪校验,因此补上”。但现有记录没有给出针对这些 JSON 接口的攻击复现,也没有先证明现有输入约束和浏览器边界不足。这里把一项技术选择直接当成了缺陷修复。

过滤器扩展到游戏 API 主机

10 月 27 日,移动网页也改为 Cookie 登录,过滤器被改成两端共用,原来限制管理构建的条件被移除。

当时已经有明确要求:移动网页使用 Cookie,小游戏等外部入口维持其原有调用方式。说明文档声称外部匿名接口不受影响,但实际实现依赖的是:

if (authenticatedOnly &&
    context.HttpContext.User.Identity?.IsAuthenticated != true)
{
    return;
}

条件只检查身份是否已认证,没有检查认证方案。虽然类名里有 Cookie,实际也会匹配通过 Bearer 认证的请求。

可选 JWT 认证触发了旧过滤器

10 月 29 日,游戏 API 加入可选 JWT 认证。调用者主动带有效 Token 时,中间件建立身份;不带 Token 的旧调用继续匿名执行。

这与旧过滤器组合后形成了故障:

客户端主动带有效 Bearer Token
  → 中间件建立已认证身份
  → 全局过滤器把已认证请求纳入防伪范围
  → POST JSON 没有防伪 Cookie 和请求令牌
  → ValidateRequestAsync 抛出校验异常
  → 过滤器返回空的 400
  → 订单 Action 尚未执行

接口有没有 [Authorize] 并不能改变这个过程。授权声明决定访问是否需要某种身份;中间件则可能在没有授权要求的端点上解析客户端主动提供的凭据。过滤器在看到身份后自行执行了另一项校验。

结果是:同一个匿名可调用的入口,带上有效 Token 后反而被误拦截。GET 正常、POST 失败,也与过滤器按方法跳过只读请求的逻辑吻合。

前端继续增加令牌获取逻辑

前端最初在打开页面时获取令牌,登录后和用户配置加载时又可能重复刷新。后续修改增加了按需获取、缓存和并发复用,解决了一些请求次数问题,却没有重新回答“这些 JSON 接口是否需要额外令牌”。

测试也沿用了这个前提,把“已登录 POST 缺少令牌应被拒绝”作为预期。JWT 测试只验证了认证中间件,没有连接 MVC 全局过滤器,因此漏掉了带有效 Token 下单被拦截的情况。测试通过验证了各自设定的行为,却没有覆盖这条实际调用链,也没有验证 CSRF 攻击是否可行。

最后移除了这套应用级防伪过滤器、令牌端点和前端获取流程,保留认证、权限、租户检查以及登录响应的禁止缓存设置。两端还关闭了 Angular 的默认防伪头注入,并清除 ABP 基类可能从旧 Cookie 附加的令牌头。

用最小 Controller 验证

我用独立的 ASP.NET Core 内存测试主机验证上述行为。示例不连接业务数据库、不创建真实订单,也不监听开发服务器端口。

可下载完整的ASP.NET Core JSON 与防伪最小实验源码。压缩包只包含 Probe.csproj 和 Program.cs,目标框架为 net10.0,引用 Microsoft.AspNetCore.TestHost 10.0.12。解压后执行:

dotnet run --project Probe.csproj

测试主机注册 AddControllersWithViews() 以获得内置 MVC 防伪过滤器所需服务,但只在对照端点上显式使用 [AutoValidateAntiforgeryToken]。普通 JSON 端点没有这个属性。这使两种行为可以在同一主机中比较。

2026 年 11 月 3 日,在 .NET 10.0.14 下的实际输出为:

运行时:.NET 10.0.14
匿名 JSON:200
Cookie 类型身份 JSON:200
Bearer 类型身份 JSON:200
URL 编码表单发送给 FromBody:415
text/plain 中放 JSON:415
multipart 表单发送给 FromBody:415
显式 MVC 防伪过滤器校验 JSON:400
显式 MVC 防伪过滤器校验 Bearer 身份 JSON:400
直接调用 ValidateRequestAsync 校验 JSON:400
相同防伪令牌第 1 次使用:200
相同防伪令牌第 2 次使用:200
公共 API 预检:204,Allow-Origin=*,Allow-Credentials=False

实验确认了以下行为:

  • 默认 JSON DTO 绑定不会接受普通表单,或者仅仅装着 JSON 字符串的 text/plain。
  • 身份已经认证,不会让普通控制器自动要求防伪令牌。
  • 主动启用 MVC 防伪过滤器,或者主动调用校验服务,会改变 JSON 请求的结果。
  • 默认防伪令牌不是用一次就失效。
  • 此公共 CORS 策略没有在预检响应中允许跨域凭据。

实验也有明确的范围限制:

  • “Cookie 类型身份”和“Bearer 类型身份”是测试中直接构造的 ClaimsPrincipal,没有验证 Cookie 加解密或 JWT 签名。
  • TestServer 不是浏览器;它不会替浏览器执行 SameSite 和 CORS 拦截。跨域 Cookie 请求能否发出,要结合浏览器规则判断,不能从 TestServer 收到请求直接推导。
  • 这里没有做实际浏览器攻击演示,没有验证线上订单、短信或支付链路,也没有把本地源码修复等同于线上发布。

CSRF Token 为什么不能替代订单防重放

重复点击、网络重试和支付回调重发,都可能产生相似的业务请求。攻击者已经取得凭据后重复调用接口,也需要另外处理。它们不等于跨站请求伪造。

ASP.NET Core 默认的防伪令牌生成与校验源码会检查令牌配对和身份等信息,但没有在每次成功校验后把该令牌标记成已消费。上面的实验也用同一对 Cookie 和请求令牌连续获得了两次 200。

重复下单或重复扣款仍要靠业务请求号、唯一约束、事务、订单状态转换和回调幂等来处理。如果要用一次性凭证,还必须在使用后记录消费状态,并拒绝再次使用。

其他项目如何判断

这次移除了误加的全局机制,小游戏继续主动携带 Bearer 凭据调用接口,无须再适配防伪令牌。

对其他项目,可以逐个入口回答这些具体问题:

  1. 请求是否修改业务状态?如果 GET 会修改状态,先检查这个接口设计,不能只关注 POST。
  2. 身份凭据是否会被浏览器自动附带?显式 Bearer 与 Cookie 不应仅凭 IsAuthenticated 合并处理。
  3. 攻击页面能构造什么媒体类型?服务器实际注册的绑定器和格式化器会不会接受?
  4. 如果必须发送跨域 JSON,预检是否会允许攻击者的来源以及携带凭据的请求?
  5. Cookie 的发送条件是什么?是否把同站误当成同源?
  6. 在这些约束下,还能否给出完整的未经用户同意的状态修改路径?

如果现有严格 JSON 输入和正确的浏览器跨域边界已经阻断了传统 CSRF 路径,就不能仅因切换 Cookie 认证而机械增加令牌往返。若端点接受浏览器可跨站提交的表单,并依赖自动携带的 Cookie,则应对这条具体路径采用合适的保护,例如框架防伪令牌或经版本核实的来源验证机制。

回到这次故障,过滤器拒绝请求的直接原因是代码主动调用了令牌校验。这个 400 说明校验生效了,却不能说明原来的接口存在可利用的 CSRF。迁移 Cookie 时如果先核对接口输入和浏览器行为,后续的令牌接口、前端缓存与刷新逻辑就不必围绕一个未经验证的前提反复修改。