Skip to content
Go back

ASP.NET Core CORS 配置顺序与生产排错

前端的登录表单发到 https://api.acme.com/auth/login,浏览器控制台里是一行每个 .NET 开发者都见过的错误:

Access to fetch at 'https://api.acme.com/auth/login' from origin
'https://app.acme.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

同一个请求在 Postman 里返回 200,部署状态健康,接口代码也没有问题。浏览器做的正是它该做的事,要改的是服务端。

Mukesh Murugan 在 CORS in ASP.NET Core (.NET 10) 里把这件事拆成了三层:CORS 到底保护谁、「配置」本身怎么写、以及只在真实基础设施前面才暴露的缓存与代理问题。他的判断很直接——中间件顺序写错和 AllowAnyOrigin 撞上 AllowCredentials,是生产环境里最常出现的两类 CORS 故障。

下面的重述保留了这个结构,并补上两处原文没有展开的内容:一是把 Vary: Origin 的判定逻辑对着 aspnetcore 源码逐行核对,二是修正原文关于 dotnet test 的一处说法。

CORS 不管什么

先把边界说清楚,后面的每个配置决定都由它推导出来。

源(origin)是协议、主机、端口的三元组。https://app.acme.comhttp://app.acme.comhttps://app.acme.com:8443 是三个不同的源。同源策略(Same-Origin Policy)从 1995 年就在浏览器里生效:A 源的页面上的 JavaScript 读不到 B 源的响应。没有它,evil.com 上的页面可以在你已登录的情况下 fetch('https://bank.com/account') 并把余额带走。

现代应用几乎必然是跨源的,SOP 会挡掉所有正常前端。CORS 就是「受控地开洞」:服务端用响应头告诉浏览器,哪些来源可以读我的响应、允许哪些方法和请求头。

这里有两个必须同时记住的事实:

再往下分一层,跨源请求按是否触发预检分成两类,区别在于真实请求会不会被发出去:

触发条件真实请求是否发出
简单请求方法是 GET/HEAD/POST;脚本只设置了 AcceptAccept-LanguageContent-LanguageRangeContent-Type 这类安全列表头;Content-Typeapplication/x-www-form-urlencodedmultipart/form-datatext/plain直接发出。浏览器只拦响应,不拦请求
预检请求带 JSON 体的 POST、任意 PUT/DELETE、带 Authorization 头等浏览器先发 OPTIONS,预检通过才发真实请求

这张表解释了一个容易被忽略的差别:简单请求的副作用可能已经落地,只是前端读不到响应;预检请求失败则连真实请求都不会发出去。这也是为什么 POST 订单这种接口,认证和校验一个都不能省。

三步配置,顺序写错就会出现假的 CORS 错误

每个支持 CORS 的 ASP.NET Core 应用都是同样的三步。

第一步,注册策略:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCors(options =>
{
    options.AddPolicy("AcmeFrontend", policy =>
    {
        policy.WithOrigins("https://app.acme.com")
              .WithMethods("GET", "POST", "PUT", "DELETE")
              .WithHeaders("Authorization", "Content-Type")
              .SetPreflightMaxAge(TimeSpan.FromMinutes(10));
    });
});

这里用显式的 WithMethodsWithHeaders,而不是 AllowAnyMethod/AllowAnyHeader。两者的功能差别不大,但配置本身变成了一份意图声明:这个前端只用这些方法和这些头。哪天冒出一个没人加过的方法,这个信号值得查。

第二步,挂中间件——这一步的次序是硬要求:

var app = builder.Build();

app.UseRouting();
app.UseCors("AcmeFrontend");
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

官方文档对此的原话是:UseCors 必须放在 UseRouting 之后、UseAuthorization 之前。两个位置各有理由:

第二个理由对应的正是最常见的误诊。预检是一个 OPTIONS 请求,浏览器不会在它上面带凭证。如果认证中间件排在 CORS 前面,它会直接返回 401,CORS 中间件根本没机会写响应头,浏览器最后报给你的是 CORS 错误。你在错误信息里找不到任何和认证有关的线索,于是开始怀疑来源列表写错了。

第三步,把策略应用上去。三种方式选一种,保持一致:

// 全局
app.UseCors("AcmeFrontend");

// 端点
app.MapGet("/api/products", (IProductService products, CancellationToken ct)
        => products.GetAllAsync(ct))
   .RequireCors("AcmeFrontend");

// 控制器
[EnableCors("AcmeFrontend")]
public class ProductsController : ControllerBase { }

官方文档在这里有两条明确提醒,值得单独记住:[EnableCors] 与中间件同时启用时两个策略都会生效,所以不要在同一个应用里混用两者;另外 [DisableCors] 关不掉由 RequireCors 开启的 CORS。

三种作用域怎么选

作用域适用场景代价
默认策略 AddDefaultPolicy单前端 + 单 API 的内部工具只有一个位置。第二个前端出现时没地方插
命名策略 AddPolicy("Name")公有站点、管理后台、移动 Web 打在同一个 API 上需要显式命名并在使用时引用
端点策略 RequireCors / [EnableCors]单个端点需要不同来源,例如公开 webhook 接收器新端点容易漏配,必须专门测 OPTIONS

按逻辑客户端各配一个命名策略最省事:AcmeFrontendAcmeAdminAcmePublic。默认策略槽位看起来整洁,但它是为「只有一个前端」这个不会长期成立的前提准备的。

来源列表应该来自配置,而不是代码

演示项目里把来源硬编码在 Program.cs 没问题,生产环境里同一个二进制要跑在 dev、staging、prod 三套环境上,来源列表就不能靠重新编译来改。

appsettings.Production.json

{
  "Cors": {
    "AcmeFrontend": {
      "Origins": ["https://app.acme.com", "https://admin.acme.com"],
      "AllowCredentials": true,
      "PreflightMaxAgeSeconds": 600
    }
  }
}

Program.cs

var corsSection = builder.Configuration.GetSection("Cors:AcmeFrontend");
var origins = corsSection.GetSection("Origins").Get<string[]>() ?? [];
var allowCredentials = corsSection.GetValue<bool>("AllowCredentials");
var maxAge = corsSection.GetValue<int>("PreflightMaxAgeSeconds");

builder.Services.AddCors(options =>
{
    options.AddPolicy("AcmeFrontend", policy =>
    {
        policy.WithOrigins(origins)
              .WithMethods("GET", "POST", "PUT", "DELETE", "PATCH")
              .WithHeaders("Authorization", "Content-Type", "X-Correlation-Id")
              .SetPreflightMaxAge(TimeSpan.FromSeconds(maxAge));

        if (allowCredentials)
        {
            policy.AllowCredentials();
        }
    });
});

Development 那份列 https://localhost:5173(Vite)或 https://localhost:4200(Angular CLI),staging 和 prod 只列真实部署的来源。

开发期还有一个更省事的选择:如果前端和后端只差一个端口,直接用前端 dev server 的代理把请求转给后端,浏览器看到的就是同源请求,CORS 全程不参与。这条路适合本地联调,但不要带进生产——生产环境里前端和 API 通常真的在不同域上。

通配符子域与动态谓词

多租户场景下每个客户拿到 https://acme.tenant.myapp.com 这样的子域,来源列表无法枚举。.NET 给了两个口子,风险差别很大。

通配符子域* 必须真的写在 origin 字符串里,单独调用 SetIsOriginAllowedToAllowWildcardSubdomains() 没有任何效果,这一点官方文档写得很明确。协议和端口仍然精确匹配,https://*.myapp.com 不会接受 http://acme.myapp.com

options.AddPolicy("TenantApps", policy =>
{
    policy.WithOrigins("https://*.myapp.com")
          .SetIsOriginAllowedToAllowWildcardSubdomains()
          .WithMethods("GET", "POST", "PUT", "DELETE")
          .WithHeaders("Authorization", "Content-Type");
});

要说清楚的是,这等于把信任边界扩大到该域下的所有子域。任何能被第三方控制的子域——客户自建的 CNAME、被遗忘的 staging 主机、市场部门指向外部供应商的域名——都会继承这份 CORS 授权。子域接管就从「一次尴尬事故」升级成「API 被绕过」。只有子域全部由你自己的部署流水线创建时才值得用。

动态谓词SetIsOriginAllowed 接受 Func<string, bool>,控制力最强,也最容易割到自己。

options.AddPolicy("TenantApps", policy =>
{
    policy.SetIsOriginAllowed(origin =>
              Uri.TryCreate(origin, UriKind.Absolute, out var uri)
              && uri.Scheme == Uri.UriSchemeHttps
              && uri.Host.EndsWith(".myapp.com", StringComparison.OrdinalIgnoreCase))
          .AllowAnyHeader()
          .AllowAnyMethod();
});

两条纪律:

凭证:AllowAnyOrigin 撞上 AllowCredentials 是运行时报错

CORS 里的「凭证」指 cookie、HTTP 认证头、TLS 客户端证书。浏览器不会在跨源请求上自动带这些,除非三件事同时成立:

如果同时调用 AllowAnyOrigin()AllowCredentials(),框架会直接抛异常。这段代码在 CorsService.EvaluatePolicy 的开头:

if (policy.AllowAnyOrigin && policy.SupportsCredentials)
{
    throw new ArgumentException(Resources.InsecureConfiguration, nameof(policy));
}

真正需要留意的是抛出时机:它在请求时抛,不在启动时抛。 应用正常启动、健康检查通过,第一个浏览器跨域请求进来才炸。这是框架在替你把关,因为 Fetch 标准本身就禁止通配符来源与凭证并存——否则互联网上任何站点都能借用户的登录态调用你的 API。

正确写法是把来源钉死:

options.AddPolicy("AcmeAuthenticated", policy =>
{
    policy.WithOrigins("https://app.acme.com")
          .AllowAnyHeader()
          .AllowAnyMethod()
          .AllowCredentials();
});

顺带澄清一个常见混淆:Authorization: Bearer <jwt> 严格来说不算 CORS 定义里的凭证,但它的存在会触发预检,所以策略里必须有 WithHeaders("Authorization")。而 AllowCredentials() 要不要加,取决于 JWT 存在哪里——存在 localStorage 里由前端显式附加,不需要;存在 HttpOnly cookie 里由浏览器自动发送,就需要。

只在生产出现的第一个问题:Vary: Origin

这是最容易通过代码评审、通过所有测试、然后在生产里跨租户泄漏的问题。

Access-Control-Allow-Origin 的值取决于请求的 Origin 头。任何位于 API 和浏览器之间、按 URL 做键的缓存——输出缓存、响应缓存中间件、CDN、反向代理——都可能把一份带着 Access-Control-Allow-Origin: https://app.acme.com 的响应,原样发给来自 https://admin.acme.com 的浏览器。

Vary: Origin 就是解法,它让缓存按请求的 Origin 分键。ASP.NET Core 会替你发这个头,但不是所有情况。源码里的判定是这样的:

if (policy.AllowAnyOrigin)
{
    result.AllowedOrigin = CorsConstants.AnyOrigin;
    result.VaryByOrigin = policy.SupportsCredentials;
}
else
{
    var origin = headers.Origin;
    result.AllowedOrigin = origin;
    result.VaryByOrigin = policy.Origins.Count > 1 || !policy.IsDefaultIsOriginAllowed;
}

第二行是关键:只有一个来源、且使用默认匹配函数的策略,VaryByOriginfalse,不发 Vary: Origin 单独看这是对的——只有一个允许来源,这个头的值只可能有一个,没什么可 vary 的。只有当第二份策略、第二个来源,或某个控制器上的 [EnableCors] 让同一个 URL 出现第二个可能值时,它才开始出错,而缓存对此一无所知。

配置是否发出 Vary: Origin风险
单一来源 + 默认匹配单独安全;同路由出现第二个策略即失效
两个及以上来源框架已正确处理
SetIsOriginAllowed / 通配符子域是(IsDefaultIsOriginAllowed 为 false)已处理,但缓存键按来源展开,命中率下降
AllowAnyOrigin() 且无凭证安全,值是常量 *
AllowAnyOrigin() + 凭证不适用直接抛异常

处理方式有三条:

curl -sI https://api.acme.com/api/products \
  -H "Origin: https://app.acme.com" | grep -i "access-control-allow-origin\|vary\|age"
curl -sI https://api.acme.com/api/products \
  -H "Origin: https://admin.acme.com" | grep -i "access-control-allow-origin\|vary\|age"

第二次返回了第一个来源,说明缓存条目已经被污染。非零的 Age 配上不匹配的来源,基本可以确诊。

只在生产出现的第二个问题:重复的响应头

容器化的 API 通常跑在 nginx、YARP、Ingress 或 API 网关后面,而这些层自己也能加 CORS 头。当代理加了 Access-Control-Allow-Origin,ASP.NET Core 也加了一次,浏览器看到两个值,直接把整个响应拒掉:

The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.acme.com, https://app.acme.com', but only one is allowed.

注意两个值往往完全相同——两层都配置正确、彼此一致,响应照样失败。这就是它特别耗时的原因。规则很简单:CORS 只由一层负责。 选应用层,把 nginx 里的 add_header 删掉,让策略和依赖它的代码一起进版本控制。只有在代理后面挂着一个自己不会做 CORS 的服务时,才把这件事交给代理。

两个相关的代理坑:

七种能稳定复现的配置错误

配置错误表现修法
生产环境用 AllowAnyOrigin()开发期最快「修好」报错的方式,然后被带进生产按环境钉住具体来源
AllowAnyOrigin + AllowCredentials,或干脆反射请求的 Origin后者绕过了框架检查,效果等同换成 WithOrigins(...) 具体列表
允许来源来自运行时可编辑的数据一次管理员失陷变成永久 CORS 绕过视为部署期设置
UseCors 放在 UseAuthentication 之后预检 OPTIONS 被 401 拦掉,浏览器报成 CORS 错误UseRouting → UseCors → UseAuthentication → UseAuthorization
忘记 WithExposedHeaders自定义响应头在 DevTools 里能看到,response.headers.get() 却是 null显式暴露,例如 Content-DispositionX-Total-Count
不设 SetPreflightMaxAge每次触发 CORS 的调用都多一次 OPTIONS设 600 秒起步
把 CORS 当安全边界浏览器读不到响应,但脚本照样能下单认证、授权、校验、限流一个都不能省

有一项值得单独展开,因为它不像 CORS 问题:跨源下载文件时文件名丢失Content-Disposition 不在浏览器默认暴露的安全列表里,所以下载能成功,存下来的却是一个没有扩展名的 download

options.AddPolicy("AcmeFrontend", policy =>
{
    policy.WithOrigins("https://app.acme.com")
          .WithExposedHeaders("Content-Disposition", "X-Total-Count", "X-Correlation-Id");
});

排查这类问题时要记住那个割裂感:头就在网络上,DevTools 里看得见,但前端代码读不到。浏览器默认只暴露 Cache-ControlContent-LanguageContent-LengthContent-TypeExpiresLast-ModifiedPragma 这几个。

验证:一条 curl 加一组集成测试

预检是最快的复现方式,不需要浏览器:

curl -i -X OPTIONS https://api.acme.com/api/products \
  -H "Origin: https://app.acme.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: authorization, content-type"

配置正确时返回 204 No Content(或 200 OK),并带上 Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers

把预检固化成测试更划算,每个允许来源一条、再加一条已知被拒绝的来源:

[Fact]
public async Task Preflight_FromAllowedOrigin_Returns204()
{
    using var factory = new WebApplicationFactory<Program>();
    using var client = factory.CreateClient();

    var request = new HttpRequestMessage(HttpMethod.Options, "/api/products");
    request.Headers.Add("Origin", "https://app.acme.com");
    request.Headers.Add("Access-Control-Request-Method", "POST");
    request.Headers.Add("Access-Control-Request-Headers", "authorization");

    var response = await client.SendAsync(request);

    Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
    Assert.Contains(
        "https://app.acme.com",
        response.Headers.GetValues("Access-Control-Allow-Origin"));
}

两个和 CORS 无关但会先把你拦住的坑。第一个是 WebApplicationFactory<Program> 需要 Program 从测试项目可见,而顶级语句生成的类是 internal,在 Program.cs 末尾加一行 public partial class Program { } 或配置 InternalsVisibleTo 即可,否则报的编译错误和 CORS 毫无关系。

第二个是运行方式。原文的说法是:xUnit v3 的测试项目本身就是可执行文件,运行器编译在内,所以用 dotnet run --project YourProject.Tests 而不是 dotnet test,理由是「.NET 10 SDK 废弃了 dotnet test 依赖的 VSTest 桥」。

前半句成立,后半句需要更正。按 官方迁移指南,.NET 10 SDK 只是为 Microsoft.Testing.Platform(MTP)提供了原生 dotnet test 支持,而且需要显式开启——在 global.json 里加一行:

{
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

没有这一行,dotnet test 仍然走 VSTest 路径,VSTest 也并没有被移除。所以结论应该改成:xUnit v3 项目用 dotnet run --project 是最直接的方式;想继续用 dotnet test,就在 global.json 里显式切到 MTP,此时 -t--filter--logger 这些参数需要按 MTP 的对应选项替换。

排错对照表

你看到的错误先检查什么
No 'Access-Control-Allow-Origin' header is present有没有策略命中这个来源;UseCors 是否在管道里、是否排在了认证之后
The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time同时开了 AllowAnyOrigin()AllowCredentials();注意这是请求时报错,不是启动时报错
Response to preflight request doesn't pass access control check: It does not have HTTP ok statusOPTIONS 返回了 4xx/5xx:认证中间件跑到 CORS 前面、全局异常处理把 CORS 错误变成 500,或路由没匹配上 OPTIONS
Request header field X-Custom-Header is not allowed前端发了策略里没声明的头,补进 WithHeaders(...)
自定义响应头在 JS 里读不到头不在安全列表里,补进 WithExposedHeaders(...)
header contains multiple values代理和应用都在加 CORS 头,只保留一层
Postman 正常、浏览器报错这是预期行为,Postman 不是浏览器,修在服务端

结语

CORS 配置得好时会彻底消失:前端正常工作,API 保持解耦,浏览器做它该做的事。配置得随意时,它就变成团队第一次上线那周被搜索最多的那个报错。

需要长期记住的模型只有一句:CORS 是服务端与浏览器之间的契约,不是 API 前面的防火墙。 具体到 ASP.NET Core,落到四个动作上——按环境钉住来源、用命名策略、把 UseCors 放在 UseRouting 之后与 UseAuthentication 之前、永远不要同时开 AllowAnyOrigin()AllowCredentials()。然后在前面还有缓存或代理时,额外确认 Vary: Origin 在边缘确实存在,并且只有一层在写 CORS 头。

认证、限流、输入校验和每个端点上的授权,仍然要各自做好。CORS 只是安全姿态里的一层。

Aide Hub 会继续整理这类把框架行为核对到源码、再落到生产排错步骤的实践,覆盖 .NET、AI 助手与软件工程。

参考


Tags


Previous

Jev 决策模型:调用方式、成本与边界

Next

TPL Dataflow 流水线要写对生命周期