diff --git a/eng/testing/linker/SupportFiles/Directory.Build.targets b/eng/testing/linker/SupportFiles/Directory.Build.targets
index 7fdcae064b82..ed49b18b57b0 100644
--- a/eng/testing/linker/SupportFiles/Directory.Build.targets
+++ b/eng/testing/linker/SupportFiles/Directory.Build.targets
@@ -95,6 +95,7 @@
+
diff --git a/src/DefaultBuilder/src/Internal/CsrfProtectionMiddleware.cs b/src/DefaultBuilder/src/Internal/CsrfProtectionMiddleware.cs
new file mode 100644
index 000000000000..3f4df4536eca
--- /dev/null
+++ b/src/DefaultBuilder/src/Internal/CsrfProtectionMiddleware.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Antiforgery;
+
+///
+/// Auto-injected middleware that enforces on incoming requests.
+/// Skips validation when the matched endpoint opted out via DisableAntiforgery()
+/// (i.e. carries with = ).
+///
+internal sealed partial class CsrfProtectionMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly ICsrfProtection _csrfProtection;
+ private readonly ILogger _logger;
+
+ public CsrfProtectionMiddleware(
+ RequestDelegate next,
+ ICsrfProtection csrfProtection,
+ ILogger logger)
+ {
+ _next = next;
+ _csrfProtection = csrfProtection;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var endpoint = context.GetEndpoint();
+ if (endpoint?.Metadata.GetMetadata() is { RequiresValidation: false })
+ {
+ await _next(context);
+ return;
+ }
+
+ if (await _csrfProtection.ValidateAsync(context) is { IsAllowed: false })
+ {
+ RequestDenied(_logger, context.Request.Method, context.Request.Path, context.Request.Headers.Origin.ToString());
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ await _next(context);
+ }
+
+ [LoggerMessage(EventId = 1, Level = LogLevel.Debug,
+ Message = "Cross-origin CSRF protection denied request {Method} {Path} from origin '{Origin}'.",
+ EventName = "CsrfRequestDenied")]
+ private static partial void RequestDenied(ILogger logger, string method, PathString path, string origin);
+}
+
diff --git a/src/DefaultBuilder/src/Internal/DefaultCsrfProtection.cs b/src/DefaultBuilder/src/Internal/DefaultCsrfProtection.cs
new file mode 100644
index 000000000000..01dfb152b9df
--- /dev/null
+++ b/src/DefaultBuilder/src/Internal/DefaultCsrfProtection.cs
@@ -0,0 +1,133 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Cors.Infrastructure;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Antiforgery;
+
+internal sealed class DefaultCsrfProtection : ICsrfProtection
+{
+ // Safe HTTP methods that do not require cross-origin validation (RFC 7231).
+ private static readonly HashSet SafeMethods = new(StringComparer.OrdinalIgnoreCase)
+ {
+ HttpMethods.Get,
+ HttpMethods.Head,
+ HttpMethods.Options,
+ HttpMethods.Trace,
+ };
+
+ ///
+ public async ValueTask ValidateAsync(HttpContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var request = context.Request;
+
+ // Step 1: Safe methods are always allowed.
+ if (SafeMethods.Contains(request.Method))
+ {
+ return CsrfProtectionResult.Allowed();
+ }
+
+ // Step 2: Sec-Fetch-Site accept path. The vast majority of legitimate browser
+ // traffic to a same-origin endpoint hits this exit without any DI lookup or string parsing.
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site#same-site
+ var secFetchSite = request.Headers["Sec-Fetch-Site"].ToString();
+ if (secFetchSite is "same-origin" or "none")
+ {
+ return CsrfProtectionResult.Allowed();
+ }
+
+ // Step 3: Check trusted origins from the CORS policy that applies to this request
+ // (per-endpoint policy first, falling back to the default policy).
+ var origin = request.Headers.Origin.ToString();
+ if (!string.IsNullOrEmpty(origin))
+ {
+ var policy = await ResolveCorsPolicyAsync(context);
+
+ // AllowAnyOrigin is intentionally NOT honored as a CSRF trust signal: it means "any browser
+ // can read this resource", which is a different concern than "any origin can mutate it on the
+ // user's behalf". Treating AllowAnyOrigin as trusted would turn this middleware into a no-op
+ // for cross-origin writes. Users who legitimately need public-read CORS + CSRF-protected writes
+ // should rely on Sec-Fetch-Site (modern browsers) / Origin-vs-Host (legacy) — which still apply
+ // here — or opt the endpoint out via DisableAntiforgery() if it has no cookie-based auth.
+ if (policy is not null && !policy.AllowAnyOrigin && policy.IsOriginAllowed(origin))
+ {
+ return CsrfProtectionResult.Allowed();
+ }
+ }
+
+ // Step 4: Sec-Fetch-Site deny path. Any value other than "same-origin"/"none"
+ // (e.g. "cross-site", "same-site") is treated as untrusted.
+ if (!string.IsNullOrEmpty(secFetchSite))
+ {
+ return CsrfProtectionResult.Denied();
+ }
+
+ // Step 5: No Sec-Fetch-Site header. Fall back to Origin vs Host comparison.
+ if (!string.IsNullOrEmpty(origin))
+ {
+ return OriginMatchesRequestHost(origin, request)
+ ? CsrfProtectionResult.Allowed()
+ : CsrfProtectionResult.Denied();
+ }
+
+ // Step 6: No Sec-Fetch-Site AND no Origin header.
+ // This is a non-browser client (curl, Postman, server-to-server).
+ // Allow the request — CSRF is a browser-based attack vector.
+ return CsrfProtectionResult.Allowed();
+ }
+
+ private static async ValueTask ResolveCorsPolicyAsync(HttpContext context)
+ {
+ var corsMetadata = context.GetEndpoint()?.Metadata.GetMetadata();
+
+ // [DisableCors] on the endpoint → no CORS-derived trust applies. Fall through to Sec-Fetch logic.
+ if (corsMetadata is IDisableCorsAttribute)
+ {
+ return null;
+ }
+
+ // Inline policy attached to the endpoint (rare, but supported by the CORS metadata model).
+ if (corsMetadata is ICorsPolicyMetadata inlinePolicyMetadata)
+ {
+ return inlinePolicyMetadata.Policy;
+ }
+
+ // [EnableCors("name")] selects a named policy; otherwise null → ICorsPolicyProvider falls back
+ // to the default policy registered via AddCors(o => o.AddDefaultPolicy(...)).
+ var policyName = (corsMetadata as IEnableCorsAttribute)?.PolicyName;
+ var provider = context.RequestServices.GetService();
+ if (provider is null)
+ {
+ return null;
+ }
+
+ return await provider.GetPolicyAsync(context, policyName);
+ }
+
+ ///
+ /// Compares the Origin header to "scheme://host[:port]" built from the request.
+ ///
+ /// value of Origin header
+ /// request being processed
+ ///
+ /// This path only runs for pre-Fetch-Metadata browsers (~2020 and earlier) and
+ /// non-browser clients, so the current allocation cost is negligible.
+ ///
+ /// true if match
+ private static bool OriginMatchesRequestHost(string origin, HttpRequest request)
+ {
+ var host = request.Host;
+ if (!host.HasValue)
+ {
+ return false;
+ }
+
+ // host.Value preserves the raw "host[:port]" form as parsed from the Host header,
+ // matching how browsers serialize the Origin header (default ports stripped on both sides).
+ return MemoryExtensions.Equals(origin, $"{request.Scheme}://{host.Value}", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj
index 0f7003b31348..7ab7c031bce9 100644
--- a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj
+++ b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj
@@ -14,14 +14,15 @@
+
-
+
@@ -45,6 +46,7 @@
+
diff --git a/src/DefaultBuilder/src/WebApplicationBuilder.cs b/src/DefaultBuilder/src/WebApplicationBuilder.cs
index d2f02b4fa74f..badf5ccc12cc 100644
--- a/src/DefaultBuilder/src/WebApplicationBuilder.cs
+++ b/src/DefaultBuilder/src/WebApplicationBuilder.cs
@@ -4,6 +4,7 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
@@ -24,6 +25,7 @@ public sealed class WebApplicationBuilder : IHostApplicationBuilder
private const string EndpointRouteBuilderKey = "__EndpointRouteBuilder";
private const string AuthenticationMiddlewareSetKey = "__AuthenticationMiddlewareSet";
private const string AuthorizationMiddlewareSetKey = "__AuthorizationMiddlewareSet";
+ private const string CsrfProtectionMiddlewareSetKey = "__CsrfProtectionMiddlewareSet";
private const string UseRoutingKey = "__UseRouting";
private readonly HostApplicationBuilder _hostApplicationBuilder;
@@ -453,6 +455,19 @@ private void ConfigureApplication(WebHostBuilderContext context, IApplicationBui
}
}
+ // Auto-inject cross-origin CSRF protection after auth.
+ if (!WebHostUtilities.ParseBool(_builtApplication.Configuration["DisableCsrfProtection"]))
+ {
+ if (serviceProviderIsService?.IsService(typeof(ICsrfProtection)) is true)
+ {
+ if (!_builtApplication.Properties.ContainsKey(CsrfProtectionMiddlewareSetKey))
+ {
+ _builtApplication.Properties[CsrfProtectionMiddlewareSetKey] = true;
+ app.UseMiddleware();
+ }
+ }
+ }
+
// Wire the source pipeline to run in the destination pipeline
var wireSourcePipeline = new WireSourcePipeline(_builtApplication);
app.Use(wireSourcePipeline.CreateMiddleware);
diff --git a/src/DefaultBuilder/src/WebHost.cs b/src/DefaultBuilder/src/WebHost.cs
index 68703a76854c..5bd55bbd61f6 100644
--- a/src/DefaultBuilder/src/WebHost.cs
+++ b/src/DefaultBuilder/src/WebHost.cs
@@ -3,6 +3,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Hosting;
@@ -13,6 +14,7 @@
using Microsoft.AspNetCore.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -273,6 +275,9 @@ private static void ConfigureWebDefaultsWorker(IWebHostBuilder builder, Action();
services.AddTransient, ForwardedHeadersOptionsSetup>();
+ // Cross-origin CSRF protection (Sec-Fetch-* / Origin header validation).
+ services.TryAddSingleton();
+
// Provide a way for the default host builder to configure routing. This probably means calling AddRouting.
// A lambda is used here because we don't want to reference AddRouting directly because of trimming.
// This avoids the overhead of calling AddRoutingCore multiple times on app startup.
diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.FunctionalTests/Microsoft.AspNetCore.FunctionalTests.csproj b/src/DefaultBuilder/test/Microsoft.AspNetCore.FunctionalTests/Microsoft.AspNetCore.FunctionalTests.csproj
index 017771e733a4..78b13ce0b13a 100644
--- a/src/DefaultBuilder/test/Microsoft.AspNetCore.FunctionalTests/Microsoft.AspNetCore.FunctionalTests.csproj
+++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.FunctionalTests/Microsoft.AspNetCore.FunctionalTests.csproj
@@ -43,6 +43,7 @@
SkipGetTargetFrameworkProperties="true" />
+
diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/CsrfProtectionIntegrationTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/CsrfProtectionIntegrationTests.cs
new file mode 100644
index 000000000000..c478cddee9d1
--- /dev/null
+++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/CsrfProtectionIntegrationTests.cs
@@ -0,0 +1,786 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.AspNetCore.Tests;
+
+public class CsrfProtectionIntegrationTests
+{
+ [Fact]
+ public async Task CsrfProtection_AutoInjected_BlocksCrossOriginPost()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AutoInjected_AllowsSameOriginPost()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "same-origin");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AutoInjected_AllowsGetRequests()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "/protected-get");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AutoInjected_AllowsNonBrowserClients()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ // No Sec-Fetch-Site, no Origin → non-browser client
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_DisabledEndpoint_AllowsCrossOrigin()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/unprotected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_DisabledViaUseSetting_AllowsCrossOrigin()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.WebHost.UseSetting("DisableCsrfProtection", "true");
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("true")]
+ [InlineData("True")]
+ [InlineData("TRUE")]
+ [InlineData("1")]
+ public async Task CsrfProtection_DisableCsrfProtectionConfig_AcceptedValues(string value)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.WebHost.UseSetting("DisableCsrfProtection", value);
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Theory]
+ [InlineData("false")]
+ [InlineData("0")]
+ [InlineData("disable")]
+ [InlineData("yes")]
+ [InlineData("")]
+ public async Task CsrfProtection_DisableCsrfProtectionConfig_RejectedValues_StillProtects(string value)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.WebHost.UseSetting("DisableCsrfProtection", value);
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_NotRegistered_AllowsCrossOrigin()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ // Remove the auto-registered ICsrfProtection to simulate disabled service
+ builder.Services.RemoveAll();
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_CustomImplementation_IsUsed()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddSingleton();
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_SecFetchSiteNone_Allowed()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "none");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_SecFetchSiteSameSite_Denied()
+ {
+ using var app = await CreateApp();
+ var client = app.GetTestClient();
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "same-site");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_TrustedOriginViaCorsDefaultPolicy_AllowsCrossOrigin()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ options.AddDefaultPolicy(policy => policy.WithOrigins("https://trusted.example.com")));
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Origin", "https://trusted.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_CorsDefaultPolicyAllowAnyOrigin_IsIgnored()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ options.AddDefaultPolicy(policy => policy.AllowAnyOrigin()));
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://evil.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_UntrustedOrigin_Denied()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ options.AddDefaultPolicy(policy => policy.WithOrigins("https://trusted.example.com")));
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://evil.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_PerEndpointEnableCors_TrustsNamedPolicyOrigins()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ options.AddPolicy("Webhook", policy => policy.WithOrigins("https://stripe.example.com")));
+ using var app = builder.Build();
+
+ app.UseCors();
+ app.MapPost("/webhook", () => "ok").RequireCors("Webhook");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/webhook");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://stripe.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_PerEndpointEnableCors_OverridesDefaultPolicy()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ {
+ options.AddDefaultPolicy(policy => policy.WithOrigins("https://app.example.com"));
+ options.AddPolicy("Webhook", policy => policy.WithOrigins("https://stripe.example.com"));
+ });
+ using var app = builder.Build();
+
+ app.UseCors();
+ app.MapPost("/webhook", () => "ok").RequireCors("Webhook");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ // Default-policy origin would be allowed app-wide, but this endpoint declared a stricter named policy.
+ var request = new HttpRequestMessage(HttpMethod.Post, "/webhook");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://app.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_PerEndpointInlineCorsPolicy_TrustsConfiguredOrigins()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors();
+ using var app = builder.Build();
+
+ app.UseCors();
+ // Inline-policy variant: RequireCors(lambda) builds a CorsPolicy and attaches it as ICorsPolicyMetadata.
+ app.MapPost("/webhook", () => "ok").RequireCors(p => p.WithOrigins("https://stripe.example.com"));
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/webhook");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://stripe.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_NoDefaultPolicy_NamedPolicyOnly_UnannotatedEndpoint_FallsThrough()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ // AddCors with only a named policy, no AddDefaultPolicy. Endpoint has no [EnableCors] either.
+ builder.Services.AddCors(options =>
+ options.AddPolicy("Webhook", policy => policy.WithOrigins("https://stripe.example.com")));
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ // The named policy's origin is NOT trusted here because the endpoint never opted into it
+ // and there's no default policy to fall back to → cross-site Sec-Fetch denies.
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://stripe.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_PerEndpointDisableCors_FallsThroughToSecFetchSite()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddCors(options =>
+ options.AddDefaultPolicy(policy => policy.WithOrigins("https://trusted.example.com")));
+ using var app = builder.Build();
+
+ // [DisableCors] tells us this endpoint has no CORS-derived trust list.
+ // The default-policy origin would have been trusted app-wide, but here it isn't.
+ app.MapPost("/no-cors", () => "ok").WithMetadata(new Cors.DisableCorsAttribute());
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/no-cors");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://trusted.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ private static async Task CreateApp()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ app.MapGet("/protected-get", () => "ok");
+ app.MapPost("/unprotected", () => "ok").DisableAntiforgery();
+
+ await app.StartAsync();
+ return app;
+ }
+
+ [Fact]
+ public async Task CsrfProtection_CustomImplementation_IsResolvedFromDIAndInvokedPerRequest()
+ {
+ // Verifies that a custom ICsrfProtection registered before Build() is the exact instance
+ // resolved by the auto-injected middleware, and that its ValidateAsync is invoked once per request.
+ var counting = new CountingCsrfProtection();
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddSingleton(counting);
+ using var app = builder.Build();
+
+ // Sanity: the instance the container returns is the one we registered.
+ Assert.Same(counting, app.Services.GetRequiredService());
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ for (var i = 0; i < 3; i++)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ var response = await client.SendAsync(request);
+ // Custom implementation always allows, so cross-site POST gets through.
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ Assert.Equal(3, counting.CallCount);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_DeniedRequest_LogsDebug()
+ {
+ var captureProvider = new CaptureLoggerProvider("Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware", LogLevel.Debug);
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Logging.ClearProviders().AddProvider(captureProvider).SetMinimumLevel(LogLevel.Debug);
+ using var app = builder.Build();
+
+ app.MapPost("/protected", () => "ok");
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected");
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://evil.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ var entry = Assert.Single(captureProvider.Entries);
+ Assert.Equal(LogLevel.Debug, entry.Level);
+ Assert.Contains("denied request POST /protected", entry.Message);
+ Assert.Contains("https://evil.example.com", entry.Message);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AllowedRequest_ReadFormUrlEncoded_Succeeds()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ using var app = builder.Build();
+
+ string? capturedName = null;
+ string? capturedColor = null;
+ app.MapPost("/form", async (HttpContext context) =>
+ {
+ var form = await context.Request.ReadFormAsync();
+ capturedName = form["name"];
+ capturedColor = form["color"];
+ return "ok";
+ });
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/form")
+ {
+ Content = new FormUrlEncodedContent(
+ [
+ new KeyValuePair("name", "alice"),
+ new KeyValuePair("color", "purple"),
+ ]),
+ };
+ request.Headers.Add("Sec-Fetch-Site", "same-origin");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("alice", capturedName);
+ Assert.Equal("purple", capturedColor);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AllowedRequest_ReadFormMultipart_Succeeds()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ using var app = builder.Build();
+
+ string? capturedName = null;
+ string? capturedFileContent = null;
+ app.MapPost("/form", async (HttpContext context) =>
+ {
+ var form = await context.Request.ReadFormAsync();
+ capturedName = form["name"];
+ var file = form.Files["upload"];
+ if (file is not null)
+ {
+ using var reader = new StreamReader(file.OpenReadStream());
+ capturedFileContent = await reader.ReadToEndAsync();
+ }
+ return "ok";
+ });
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var multipart = new MultipartFormDataContent
+ {
+ { new StringContent("alice"), "name" },
+ { new StringContent("hello world"), "upload", "greeting.txt" },
+ };
+ var request = new HttpRequestMessage(HttpMethod.Post, "/form") { Content = multipart };
+ request.Headers.Add("Sec-Fetch-Site", "same-origin");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("alice", capturedName);
+ Assert.Equal("hello world", capturedFileContent);
+ }
+
+ [Fact]
+ public async Task CsrfProtection_DeniedRequest_DoesNotReachFormReadingEndpoint()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ using var app = builder.Build();
+
+ var endpointInvoked = false;
+ app.MapPost("/form", async (HttpContext context) =>
+ {
+ endpointInvoked = true;
+ await context.Request.ReadFormAsync();
+ return "ok";
+ });
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var request = new HttpRequestMessage(HttpMethod.Post, "/form")
+ {
+ Content = new FormUrlEncodedContent(new[] { new KeyValuePair("name", "alice") }),
+ };
+ request.Headers.Add("Sec-Fetch-Site", "cross-site");
+ request.Headers.Add("Origin", "https://evil.example.com");
+
+ var response = await client.SendAsync(request);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ Assert.False(endpointInvoked, "CSRF middleware should short-circuit before reaching the endpoint.");
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AndAntiforgeryMiddleware_FormReadRejectsMissingToken()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAntiforgery();
+ using var app = builder.Build();
+
+ app.UseAntiforgery();
+ IAntiforgeryValidationFeature? capturedFeature = null;
+ app.MapPost("/protected", (HttpContext ctx) =>
+ {
+ capturedFeature = ctx.Features.Get();
+ return "ok";
+ }).WithMetadata(new RequireAntiforgeryTokenAttribute());
+
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ // Same-origin POST with no antiforgery token. CSRF middleware allows (Sec-Fetch-Site=same-origin)
+ // but antiforgery middleware must still record the missing token on IAntiforgeryValidationFeature.
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected")
+ {
+ Content = new FormUrlEncodedContent([new KeyValuePair("name", "alice")]),
+ };
+ request.Headers.Add("Sec-Fetch-Site", "same-origin");
+
+ var response = await client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(capturedFeature);
+ Assert.False(capturedFeature!.IsValid, "Antiforgery middleware should record the missing token even when CSRF protection allowed the request.");
+ }
+
+ [Fact]
+ public async Task CsrfProtection_AndAntiforgeryMiddleware_ValidTokenSucceeds()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAntiforgery();
+ using var app = builder.Build();
+
+ app.UseAntiforgery();
+ IAntiforgeryValidationFeature? capturedFeature = null;
+ app.MapPost("/protected", (HttpContext ctx) =>
+ {
+ capturedFeature = ctx.Features.Get();
+ return "ok";
+ }).WithMetadata(new RequireAntiforgeryTokenAttribute());
+
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ // Generate a valid token pair (cookie + form field) via the configured IAntiforgery service.
+ var antiforgery = app.Services.GetRequiredService();
+ var options = app.Services.GetRequiredService>().Value;
+ var tokens = antiforgery.GetAndStoreTokens(new DefaultHttpContext { RequestServices = app.Services });
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/protected")
+ {
+ Content = new FormUrlEncodedContent(new[]
+ {
+ new KeyValuePair(options.FormFieldName, tokens.RequestToken!),
+ }),
+ };
+ request.Headers.Add("Sec-Fetch-Site", "same-origin");
+ request.Headers.Add("Cookie", $"{options.Cookie.Name}={tokens.CookieToken}");
+
+ var response = await client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(capturedFeature);
+ Assert.True(capturedFeature!.IsValid, "Antiforgery middleware should accept the valid token; CSRF protection also allowed the same-origin request.");
+ }
+
+ [Theory]
+ [InlineData("same-origin", /* withToken */ true, HttpStatusCode.OK, /* endpointInvoked */ true)] // Legitimate Blazor form post
+ [InlineData("same-origin", /* withToken */ false, HttpStatusCode.OK, /* endpointInvoked */ true)] // Endpoint runs but IAntiforgeryValidationFeature reports invalid
+ [InlineData("cross-site", /* withToken */ true, HttpStatusCode.BadRequest, /* endpointInvoked */ false)] // CSRF middleware short-circuits before the form is even read
+ [InlineData("cross-site", /* withToken */ false, HttpStatusCode.BadRequest, /* endpointInvoked */ false)]
+ public async Task CsrfProtection_BlazorShapedEndpoint_LayersWithAntiforgery(
+ string secFetchSite,
+ bool withToken,
+ HttpStatusCode expectedStatus,
+ bool expectedEndpointInvoked)
+ {
+ // Blazor server-rendered components register every page endpoint with
+ // `RequireAntiforgeryTokenAttribute` metadata (see `RazorComponentEndpointFactory`)
+ // This test simulates that shape without pulling in the Blazor packages.
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAntiforgery();
+ using var app = builder.Build();
+
+ app.UseAntiforgery();
+
+ var endpointInvoked = false;
+ IAntiforgeryValidationFeature? capturedFeature = null;
+ app.MapPost("/page", (HttpContext ctx) =>
+ {
+ endpointInvoked = true;
+ capturedFeature = ctx.Features.Get();
+ return "ok";
+ }).WithMetadata(new RequireAntiforgeryTokenAttribute()); // Same metadata Blazor adds per page
+
+ await app.StartAsync();
+
+ var client = app.GetTestClient();
+ var antiforgery = app.Services.GetRequiredService();
+ var options = app.Services.GetRequiredService>().Value;
+ var tokens = antiforgery.GetAndStoreTokens(new DefaultHttpContext { RequestServices = app.Services });
+
+ var formFields = new List>
+ {
+ new("name", "alice"),
+ };
+ if (withToken)
+ {
+ formFields.Add(new KeyValuePair(options.FormFieldName, tokens.RequestToken!));
+ }
+
+ var request = new HttpRequestMessage(HttpMethod.Post, "/page")
+ {
+ Content = new FormUrlEncodedContent(formFields),
+ };
+ request.Headers.Add("Sec-Fetch-Site", secFetchSite);
+ if (withToken)
+ {
+ request.Headers.Add("Cookie", $"{options.Cookie.Name}={tokens.CookieToken}");
+ }
+
+ var response = await client.SendAsync(request);
+
+ Assert.Equal(expectedStatus, response.StatusCode);
+ Assert.Equal(expectedEndpointInvoked, endpointInvoked);
+
+ if (expectedEndpointInvoked)
+ {
+ // When the endpoint ran, antiforgery's IsValid state should reflect whether a valid token was supplied.
+ Assert.NotNull(capturedFeature);
+ Assert.Equal(withToken, capturedFeature!.IsValid);
+ }
+ }
+
+ private sealed class AlwaysAllowCsrfProtection : ICsrfProtection
+ {
+ public ValueTask ValidateAsync(HttpContext context)
+ => new(CsrfProtectionResult.Allowed());
+ }
+
+ private sealed class CountingCsrfProtection : ICsrfProtection
+ {
+ public int CallCount { get; private set; }
+
+ public ValueTask ValidateAsync(HttpContext context)
+ {
+ CallCount++;
+ return new ValueTask(CsrfProtectionResult.Allowed());
+ }
+ }
+
+ private sealed class CaptureLoggerProvider : ILoggerProvider
+ {
+ private readonly string _categoryFilter;
+ private readonly LogLevel _minLevel;
+
+ public List Entries { get; } = new();
+
+ public CaptureLoggerProvider(string categoryFilter, LogLevel minLevel)
+ {
+ _categoryFilter = categoryFilter;
+ _minLevel = minLevel;
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ => string.Equals(categoryName, _categoryFilter, StringComparison.Ordinal)
+ ? new CaptureLogger(this, _minLevel)
+ : NullLogger.Instance;
+
+ public void Dispose() { }
+
+ internal sealed record LogEntry(LogLevel Level, EventId EventId, string Message);
+
+ private sealed class CaptureLogger : ILogger
+ {
+ private readonly CaptureLoggerProvider _provider;
+ private readonly LogLevel _minLevel;
+
+ public CaptureLogger(CaptureLoggerProvider provider, LogLevel minLevel)
+ {
+ _provider = provider;
+ _minLevel = minLevel;
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel;
+
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ lock (_provider.Entries)
+ {
+ _provider.Entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception)));
+ }
+ }
+ }
+ }
+}
diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/DefaultCsrfProtectionTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/DefaultCsrfProtectionTests.cs
new file mode 100644
index 000000000000..69539b063058
--- /dev/null
+++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/DefaultCsrfProtectionTests.cs
@@ -0,0 +1,295 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Cors.Infrastructure;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Tests;
+
+public class DefaultCsrfProtectionTests
+{
+ private readonly DefaultCsrfProtection _validator = new();
+
+ private static HttpContext CreateContext(
+ string method = "POST",
+ string? secFetchSite = null,
+ string? origin = null,
+ string scheme = "https",
+ string host = "example.com",
+ int? port = null,
+ IServiceProvider? services = null,
+ Endpoint? endpoint = null)
+ {
+ var context = new DefaultHttpContext();
+ context.RequestServices = services ?? new ServiceCollection().BuildServiceProvider();
+ context.Request.Method = method;
+ context.Request.Scheme = scheme;
+
+ if (port.HasValue)
+ {
+ context.Request.Host = new HostString(host, port.Value);
+ }
+ else
+ {
+ context.Request.Host = new HostString(host);
+ }
+
+ if (secFetchSite is not null)
+ {
+ context.Request.Headers["Sec-Fetch-Site"] = secFetchSite;
+ }
+
+ if (origin is not null)
+ {
+ context.Request.Headers["Origin"] = origin;
+ }
+
+ if (endpoint is not null)
+ {
+ context.SetEndpoint(endpoint);
+ }
+
+ return context;
+ }
+
+ private static IServiceProvider BuildCorsServices(Action configure)
+ {
+ var services = new ServiceCollection();
+ services.AddCors(configure);
+ return services.BuildServiceProvider();
+ }
+
+ private static Endpoint EndpointWithMetadata(params object[] metadata)
+ => new Endpoint(_ => Task.CompletedTask, new EndpointMetadataCollection(metadata), "test");
+
+ // Step 1: Safe methods are always allowed regardless of headers.
+
+ [Theory]
+ [InlineData("GET")]
+ [InlineData("HEAD")]
+ [InlineData("OPTIONS")]
+ [InlineData("TRACE")]
+ [InlineData("get")]
+ [InlineData("Get")]
+ public async Task SafeMethods_AlwaysAllowed(string method)
+ {
+ var context = CreateContext(method: method, secFetchSite: "cross-site", origin: "https://evil.com");
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ // Step 2: Trusted origins (from the applicable CORS policy) allow cross-origin requests.
+
+ [Fact]
+ public async Task TrustedOrigin_FromDefaultPolicy_Allowed()
+ {
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.WithOrigins("https://trusted.com")));
+ var context = CreateContext(origin: "https://trusted.com", secFetchSite: "cross-site", services: services);
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task TrustedOrigin_FromNamedPolicyOnEndpoint_Allowed()
+ {
+ var services = BuildCorsServices(o => o.AddPolicy("Webhook", p => p.WithOrigins("https://stripe.com")));
+ var endpoint = EndpointWithMetadata(new EnableCorsAttribute("Webhook"));
+ var context = CreateContext(origin: "https://stripe.com", secFetchSite: "cross-site", services: services, endpoint: endpoint);
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task TrustedOrigin_FromInlinePolicyOnEndpoint_Allowed()
+ {
+ var policy = new CorsPolicyBuilder().WithOrigins("https://inline.example.com").Build();
+ var endpoint = EndpointWithMetadata(new CorsPolicyMetadata(policy));
+ var context = CreateContext(origin: "https://inline.example.com", secFetchSite: "cross-site", endpoint: endpoint);
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task InlinePolicyOnEndpoint_Overrides_DefaultPolicy()
+ {
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.WithOrigins("https://app.example.com")));
+ var inlinePolicy = new CorsPolicyBuilder().WithOrigins("https://inline.example.com").Build();
+ var endpoint = EndpointWithMetadata(new CorsPolicyMetadata(inlinePolicy));
+
+ // Default-policy origin would be allowed app-wide, but this endpoint declared a stricter inline policy.
+ var context = CreateContext(origin: "https://app.example.com", secFetchSite: "cross-site", services: services, endpoint: endpoint);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NamedPolicyOnEndpoint_Overrides_DefaultPolicy()
+ {
+ var services = BuildCorsServices(o =>
+ {
+ o.AddDefaultPolicy(p => p.WithOrigins("https://app.example.com"));
+ o.AddPolicy("Webhook", p => p.WithOrigins("https://stripe.com"));
+ });
+ var endpoint = EndpointWithMetadata(new EnableCorsAttribute("Webhook"));
+
+ // Default-policy origin would be allowed app-wide, but this endpoint declared a stricter named policy.
+ var context = CreateContext(origin: "https://app.example.com", secFetchSite: "cross-site", services: services, endpoint: endpoint);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task UnknownNamedPolicyOnEndpoint_FallsThroughToSecFetchSite()
+ {
+ // Endpoint references a policy name that was never registered → provider returns null → fall through.
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.WithOrigins("https://trusted.com")));
+ var endpoint = EndpointWithMetadata(new EnableCorsAttribute("Nonexistent"));
+ var context = CreateContext(origin: "https://trusted.com", secFetchSite: "cross-site", services: services, endpoint: endpoint);
+ // Even though "https://trusted.com" is in the default policy, the endpoint specified a different named policy
+ // which doesn't exist, so no CORS-trust applies and Sec-Fetch denies the cross-site request.
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoDefaultPolicy_NamedPolicyOnly_UnannotatedEndpoint_FallsThroughToSecFetchSite()
+ {
+ // AddCors is called and a named policy is registered, but no default policy and no [EnableCors] on endpoint.
+ // The provider tries default policy name, finds nothing → returns null → fall through.
+ var services = BuildCorsServices(o => o.AddPolicy("Webhook", p => p.WithOrigins("https://stripe.com")));
+ var context = CreateContext(origin: "https://stripe.com", secFetchSite: "cross-site", services: services);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task DisableCorsOnEndpoint_SkipsCorsTrust_FallsThroughToSecFetchSite()
+ {
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.WithOrigins("https://trusted.com")));
+ var endpoint = EndpointWithMetadata(new DisableCorsAttribute());
+ var context = CreateContext(origin: "https://trusted.com", secFetchSite: "cross-site", services: services, endpoint: endpoint);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task AllowAnyOriginPolicy_IsIgnored_DoesNotTrustEverything()
+ {
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.AllowAnyOrigin()));
+ var context = CreateContext(origin: "https://evil.com", secFetchSite: "cross-site", services: services);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoCorsRegistration_FallsThroughToSecFetchSite()
+ {
+ var context = CreateContext(origin: "https://untrusted.com", secFetchSite: "cross-site");
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task UntrustedOrigin_DeniedBySecFetchSite()
+ {
+ var services = BuildCorsServices(o => o.AddDefaultPolicy(p => p.WithOrigins("https://trusted.com")));
+ var context = CreateContext(origin: "https://untrusted.com", secFetchSite: "cross-site", services: services);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ // Step 3: Sec-Fetch-Site header validation.
+
+ [Theory]
+ [InlineData("same-origin")]
+ [InlineData("none")]
+ public async Task SecFetchSite_AllowedValues(string secFetchSite)
+ {
+ var context = CreateContext(secFetchSite: secFetchSite);
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Theory]
+ [InlineData("same-site")]
+ [InlineData("cross-site")]
+ [InlineData("unknown-value")]
+ public async Task SecFetchSite_DeniedValues(string secFetchSite)
+ {
+ var context = CreateContext(secFetchSite: secFetchSite);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ // Step 4: No Sec-Fetch-Site header → fall back to Origin vs Host comparison.
+
+ [Fact]
+ public async Task NoSecFetchSite_OriginMatchesHost_Allowed()
+ {
+ var context = CreateContext(origin: "https://example.com", host: "example.com");
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_OriginDiffersFromHost_Denied()
+ {
+ var context = CreateContext(origin: "https://other.com", host: "example.com");
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_OriginMatchesHostWithExplicitDefaultPort_Allowed()
+ {
+ // Host header has no port (default 443); Origin omits port too. Equal → allowed.
+ var context = CreateContext(origin: "https://example.com", host: "example.com");
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_OriginAndHostDifferOnPort_Denied()
+ {
+ var context = CreateContext(origin: "https://example.com:8080", host: "example.com", port: 8443);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_Ipv6OriginMatchesHost_Allowed()
+ {
+ var context = CreateContext(origin: "https://[::1]:8080", host: "[::1]", port: 8080);
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_Ipv6OriginDiffersOnPort_Denied()
+ {
+ var context = CreateContext(origin: "https://[::1]:9090", host: "[::1]", port: 8080);
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_MalformedOrigin_Denied()
+ {
+ // No scheme prefix.
+ var context = CreateContext(origin: "example.com", host: "example.com");
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NoSecFetchSite_NoHost_Denied()
+ {
+ var context = new DefaultHttpContext();
+ context.RequestServices = new ServiceCollection().BuildServiceProvider();
+ context.Request.Method = "POST";
+ context.Request.Scheme = "https";
+ context.Request.Headers["Origin"] = "https://example.com";
+ // No Host header set → Host.HasValue is false → cannot determine request origin
+ Assert.False((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ // Step 5: No Sec-Fetch-Site AND no Origin → non-browser → allowed.
+
+ [Fact]
+ public async Task NoHeaders_NonBrowserClient_Allowed()
+ {
+ var context = CreateContext();
+ Assert.True((await _validator.ValidateAsync(context)).IsAllowed);
+ }
+
+ [Fact]
+ public async Task NullContext_ThrowsArgumentNullException()
+ {
+ await Assert.ThrowsAsync(async () => await _validator.ValidateAsync(null!));
+ }
+}
diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj
index 96c50fcacf23..42fdcd65ae8d 100644
--- a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj
+++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/Microsoft.AspNetCore.Tests.csproj
@@ -1,4 +1,4 @@
-
+
$(DefaultNetCoreTargetFramework)
@@ -6,10 +6,12 @@
+
+
diff --git a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/WebApplicationTests.cs b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/WebApplicationTests.cs
index 5353602e357f..417d2dd2ac3f 100644
--- a/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/WebApplicationTests.cs
+++ b/src/DefaultBuilder/test/Microsoft.AspNetCore.Tests/WebApplicationTests.cs
@@ -2751,6 +2751,7 @@ public async Task DebugView_UseMiddleware_HasEndpointsAndAuth_Run_HasAutomaticMi
m => Assert.Equal("Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware", m),
m => Assert.Equal("Microsoft.AspNetCore.Authentication.AuthenticationMiddleware", m),
m => Assert.Equal("Microsoft.AspNetCore.Authorization.AuthorizationMiddlewareInternal", m),
+ m => Assert.Equal("Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware", m),
m => Assert.Equal(typeof(MiddlewareWithInterface).FullName, m),
m => Assert.Equal("Microsoft.AspNetCore.Routing.EndpointMiddleware", m));
}
@@ -2770,7 +2771,8 @@ public async Task DebugView_NoMiddleware_Run_HasAutomaticMiddleware()
var debugView = new WebApplication.WebApplicationDebugView(app);
Assert.Collection(debugView.Middleware,
- m => Assert.Equal("Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware", m));
+ m => Assert.Equal("Microsoft.AspNetCore.HostFiltering.HostFilteringMiddleware", m),
+ m => Assert.Equal("Microsoft.AspNetCore.Antiforgery.CsrfProtectionMiddleware", m));
}
[Fact]
diff --git a/src/Hosting/Hosting/src/Microsoft.AspNetCore.Hosting.csproj b/src/Hosting/Hosting/src/Microsoft.AspNetCore.Hosting.csproj
index a1ff501115da..9e73871995cf 100644
--- a/src/Hosting/Hosting/src/Microsoft.AspNetCore.Hosting.csproj
+++ b/src/Hosting/Hosting/src/Microsoft.AspNetCore.Hosting.csproj
@@ -21,6 +21,7 @@
+
diff --git a/src/Http/Http.Abstractions/src/Antiforgery/CsrfProtectionResult.cs b/src/Http/Http.Abstractions/src/Antiforgery/CsrfProtectionResult.cs
new file mode 100644
index 000000000000..66d2c0b58f54
--- /dev/null
+++ b/src/Http/Http.Abstractions/src/Antiforgery/CsrfProtectionResult.cs
@@ -0,0 +1,33 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Antiforgery;
+
+///
+/// Represents the result of cross-origin antiforgery request validation.
+///
+public sealed class CsrfProtectionResult
+{
+ private static readonly CsrfProtectionResult _allowed = new(isAllowed: true);
+ private static readonly CsrfProtectionResult _denied = new(isAllowed: false);
+
+ private CsrfProtectionResult(bool isAllowed)
+ {
+ IsAllowed = isAllowed;
+ }
+
+ ///
+ /// Gets a value indicating whether the request is allowed.
+ ///
+ public bool IsAllowed { get; }
+
+ ///
+ /// Returns a indicating the request is allowed.
+ ///
+ public static CsrfProtectionResult Allowed() => _allowed;
+
+ ///
+ /// Returns a indicating the request is denied.
+ ///
+ public static CsrfProtectionResult Denied() => _denied;
+}
diff --git a/src/Http/Http.Abstractions/src/Antiforgery/ICsrfProtection.cs b/src/Http/Http.Abstractions/src/Antiforgery/ICsrfProtection.cs
new file mode 100644
index 000000000000..222e9ecf3584
--- /dev/null
+++ b/src/Http/Http.Abstractions/src/Antiforgery/ICsrfProtection.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Antiforgery;
+
+///
+/// Provides cross-origin request protection based on Fetch Metadata headers
+/// (Sec-Fetch-Site) and Origin header validation. This is a lightweight
+/// defense against CSRF attacks that does not require tokens or DataProtection.
+///
+public interface ICsrfProtection
+{
+ ///
+ /// Validates whether the request should be allowed based on its origin.
+ ///
+ /// The associated with the current request.
+ ///
+ /// A that resolves to a whose
+ /// is when the request is
+ /// same-origin, from a trusted origin, uses a safe HTTP method, or originates from a non-browser
+ /// client; otherwise .
+ ///
+ ValueTask ValidateAsync(HttpContext context);
+}
diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..46b16d50b113 100644
--- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
+++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
@@ -1 +1,7 @@
#nullable enable
+Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult
+Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult.IsAllowed.get -> bool
+static Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult.Allowed() -> Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult!
+static Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult.Denied() -> Microsoft.AspNetCore.Antiforgery.CsrfProtectionResult!
+Microsoft.AspNetCore.Antiforgery.ICsrfProtection
+Microsoft.AspNetCore.Antiforgery.ICsrfProtection.ValidateAsync(Microsoft.AspNetCore.Http.HttpContext! context) -> System.Threading.Tasks.ValueTask
diff --git a/src/Http/Http.Abstractions/test/Microsoft.AspNetCore.Http.Abstractions.Tests.csproj b/src/Http/Http.Abstractions/test/Microsoft.AspNetCore.Http.Abstractions.Tests.csproj
index c5f91b713d47..b3d2397dd246 100644
--- a/src/Http/Http.Abstractions/test/Microsoft.AspNetCore.Http.Abstractions.Tests.csproj
+++ b/src/Http/Http.Abstractions/test/Microsoft.AspNetCore.Http.Abstractions.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj b/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj
index b3e5348edda6..d8546f7389fd 100644
--- a/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj
+++ b/src/Http/Http.Extensions/test/Microsoft.AspNetCore.Http.Extensions.Tests.csproj
@@ -16,6 +16,7 @@
+
diff --git a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
index 41422e7d5109..10b0c7b328e7 100644
--- a/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
+++ b/src/Http/Routing/test/UnitTests/Microsoft.AspNetCore.Routing.Tests.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj b/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj
index f70154bf3967..a916995b647b 100644
--- a/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj
+++ b/src/Middleware/Diagnostics/test/UnitTests/Microsoft.AspNetCore.Diagnostics.Tests.csproj
@@ -53,6 +53,7 @@
+
diff --git a/src/Middleware/Rewrite/test/Microsoft.AspNetCore.Rewrite.Tests.csproj b/src/Middleware/Rewrite/test/Microsoft.AspNetCore.Rewrite.Tests.csproj
index beccff335a2c..bbe598e4379c 100644
--- a/src/Middleware/Rewrite/test/Microsoft.AspNetCore.Rewrite.Tests.csproj
+++ b/src/Middleware/Rewrite/test/Microsoft.AspNetCore.Rewrite.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Mvc/Mvc.ViewFeatures/src/Filters/AntiforgeryApplicationModelProvider.cs b/src/Mvc/Mvc.ViewFeatures/src/Filters/AntiforgeryApplicationModelProvider.cs
index 0abe958f8c54..3a50767a6498 100644
--- a/src/Mvc/Mvc.ViewFeatures/src/Filters/AntiforgeryApplicationModelProvider.cs
+++ b/src/Mvc/Mvc.ViewFeatures/src/Filters/AntiforgeryApplicationModelProvider.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Linq;
using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc.Core.Filters;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
@@ -34,12 +35,30 @@ public void OnProvidersExecuting(ApplicationModelProviderContext context)
controllerFilterAdded = true;
}
+ // Connect [IgnoreAntiforgeryToken] applied to the controller into endpoint metadata.
+ if (controllerModel.Attributes.OfType().Any())
+ {
+ foreach (var selector in controllerModel.Selectors)
+ {
+ selector.EndpointMetadata.Add(AntiforgeryMetadata.ValidationNotRequired);
+ }
+ }
+
foreach (var actionModel in controllerModel.Actions)
{
if (HasValidAntiforgeryMetadata(actionModel.Attributes, actionModel.Filters) && !controllerFilterAdded)
{
actionModel.Filters.Add(AntiforgeryMiddlewareAuthorizationFilter);
}
+
+ // Connect [IgnoreAntiforgeryToken] on an action to the endpoint metadata.
+ if (actionModel.Attributes.OfType().Any())
+ {
+ foreach (var selector in actionModel.Selectors)
+ {
+ selector.EndpointMetadata.Add(AntiforgeryMetadata.ValidationNotRequired);
+ }
+ }
}
}
}
diff --git a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj
index 90bf2b0f91f8..9e145da1bbda 100644
--- a/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj
+++ b/src/Mvc/Mvc.ViewFeatures/src/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj
@@ -42,6 +42,7 @@
+
diff --git a/src/Mvc/Mvc.ViewFeatures/test/Filters/AntiforgeryApplicationModelProviderTest.cs b/src/Mvc/Mvc.ViewFeatures/test/Filters/AntiforgeryApplicationModelProviderTest.cs
index f69d530b1ec8..334e674ff797 100644
--- a/src/Mvc/Mvc.ViewFeatures/test/Filters/AntiforgeryApplicationModelProviderTest.cs
+++ b/src/Mvc/Mvc.ViewFeatures/test/Filters/AntiforgeryApplicationModelProviderTest.cs
@@ -139,6 +139,126 @@ public void ThrowsIfMultipleAntiforgeryAttributesAreApplied(Type controllerType)
exception.Message);
}
+ [Fact]
+ public void IgnoreAntiforgeryTokenOnAction_AddsValidationNotRequiredEndpointMetadata()
+ {
+ var provider = new AntiforgeryApplicationModelProvider(
+ Options.Create(new MvcOptions()),
+ NullLogger.Instance);
+ var context = CreateProviderContext(typeof(IgnoreAntiforgeryTokenOnActionController));
+
+ // Act
+ provider.OnProvidersExecuting(context);
+
+ // Assert
+ var controller = Assert.Single(context.Result.Controllers);
+ Assert.Collection(controller.Actions,
+ ignoredAction =>
+ {
+ Assert.Equal(nameof(IgnoreAntiforgeryTokenOnActionController.Ignored), ignoredAction.ActionName);
+ var selector = Assert.Single(ignoredAction.Selectors);
+ var metadata = selector.EndpointMetadata.OfType().LastOrDefault();
+ Assert.NotNull(metadata);
+ Assert.False(metadata.RequiresValidation);
+ },
+ normalAction =>
+ {
+ Assert.Equal(nameof(IgnoreAntiforgeryTokenOnActionController.Normal), normalAction.ActionName);
+ var selector = Assert.Single(normalAction.Selectors);
+ Assert.Empty(selector.EndpointMetadata.OfType());
+ });
+ }
+
+ [Fact]
+ public void IgnoreAntiforgeryTokenOnController_AddsValidationNotRequiredEndpointMetadataToControllerSelectors()
+ {
+ var provider = new AntiforgeryApplicationModelProvider(
+ Options.Create(new MvcOptions()),
+ NullLogger.Instance);
+ var context = CreateProviderContext(typeof(IgnoreAntiforgeryTokenOnControllerController));
+
+ // Act
+ provider.OnProvidersExecuting(context);
+
+ // Assert
+ var controller = Assert.Single(context.Result.Controllers);
+ var controllerSelector = Assert.Single(controller.Selectors);
+ var metadata = controllerSelector.EndpointMetadata.OfType().LastOrDefault();
+ Assert.NotNull(metadata);
+ Assert.False(metadata.RequiresValidation);
+
+ // The action's own selectors should still be untouched; the framework's
+ // ActionAttributeRouteModel.FlattenSelectors merges controller metadata into them later.
+ var action = Assert.Single(controller.Actions);
+ var actionSelector = Assert.Single(action.Selectors);
+ Assert.Empty(actionSelector.EndpointMetadata.OfType());
+ }
+
+ [Fact]
+ public void IgnoreAntiforgeryTokenOnAction_OverridesAutoValidateAntiforgeryTokenOnController()
+ {
+ var provider = new AntiforgeryApplicationModelProvider(
+ Options.Create(new MvcOptions()),
+ NullLogger.Instance);
+ var context = CreateProviderContext(typeof(AutoValidateWithIgnoredActionController));
+
+ // Act
+ provider.OnProvidersExecuting(context);
+
+ // Assert: action selector ends with a not-required IAntiforgeryMetadata so GetMetadata()
+ // (last-wins) reports the action-level preference. Controller-level metadata is merged
+ // before action-level metadata by ActionAttributeRouteModel.FlattenSelectors.
+ var controller = Assert.Single(context.Result.Controllers);
+ var action = Assert.Single(controller.Actions);
+ var actionSelector = Assert.Single(action.Selectors);
+ var lastMetadata = actionSelector.EndpointMetadata.OfType().LastOrDefault();
+ Assert.NotNull(lastMetadata);
+ Assert.False(lastMetadata.RequiresValidation);
+ }
+
+ [Fact]
+ public void NoAntiforgeryAttributes_DoesNotAddEndpointMetadata()
+ {
+ var provider = new AntiforgeryApplicationModelProvider(
+ Options.Create(new MvcOptions()),
+ NullLogger.Instance);
+ var context = CreateProviderContext(typeof(EmptyController));
+
+ // Act
+ provider.OnProvidersExecuting(context);
+
+ // Assert
+ var controller = Assert.Single(context.Result.Controllers);
+ foreach (var selector in controller.Selectors)
+ {
+ Assert.Empty(selector.EndpointMetadata.OfType());
+ }
+ var action = Assert.Single(controller.Actions);
+ foreach (var selector in action.Selectors)
+ {
+ Assert.Empty(selector.EndpointMetadata.OfType());
+ }
+ }
+
+ [Fact]
+ public void EnableEndpointRoutingDisabled_DoesNotAddEndpointMetadata()
+ {
+ var provider = new AntiforgeryApplicationModelProvider(
+ Options.Create(new MvcOptions { EnableEndpointRouting = false }),
+ NullLogger.Instance);
+ var context = CreateProviderContext(typeof(IgnoreAntiforgeryTokenOnControllerController));
+
+ // Act
+ provider.OnProvidersExecuting(context);
+
+ // Assert
+ var controller = Assert.Single(context.Result.Controllers);
+ foreach (var selector in controller.Selectors)
+ {
+ Assert.Empty(selector.EndpointMetadata.OfType());
+ }
+ }
+
private static ApplicationModelProviderContext CreateProviderContext(Type controllerType)
{
var defaultProvider = new DefaultApplicationModelProvider(
@@ -238,4 +358,29 @@ private class AntiforgeryMetadataWithActionsController
[RequireAntiforgeryToken(false)]
public IActionResult Post2() => null;
}
+
+ private class IgnoreAntiforgeryTokenOnActionController
+ {
+ [HttpPost("ignored")]
+ [IgnoreAntiforgeryToken]
+ public IActionResult Ignored() => null;
+
+ [HttpPost("normal")]
+ public IActionResult Normal() => null;
+ }
+
+ [IgnoreAntiforgeryToken]
+ private class IgnoreAntiforgeryTokenOnControllerController
+ {
+ [HttpPost]
+ public IActionResult Post() => null;
+ }
+
+ [AutoValidateAntiforgeryToken]
+ private class AutoValidateWithIgnoredActionController
+ {
+ [HttpPost("webhook")]
+ [IgnoreAntiforgeryToken]
+ public IActionResult Webhook() => null;
+ }
}
diff --git a/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj b/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj
index ed1cbdd00b1b..51bbb2beb3cc 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj
+++ b/src/Mvc/test/Mvc.FunctionalTests/Microsoft.AspNetCore.Mvc.FunctionalTests.csproj
@@ -51,6 +51,7 @@
+
diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj
index ecb350762daf..ec7bcd81b6ce 100644
--- a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj
+++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilderException/SimpleWebSiteWithWebApplicationBuilderException.csproj
@@ -7,6 +7,7 @@
+
diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
index 653f473b7b57..6a36303bc0f1 100644
--- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
+++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
@@ -38,6 +38,7 @@
+
diff --git a/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj b/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj
index e6a17f224e46..4bd3d77120b2 100644
--- a/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj
+++ b/src/Security/Authorization/test/Microsoft.AspNetCore.Authorization.Test.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/Hosting/Hosting/src/Internal/WebHostUtilities.cs b/src/Shared/WebHostUtilities/WebHostUtilities.cs
similarity index 100%
rename from src/Hosting/Hosting/src/Internal/WebHostUtilities.cs
rename to src/Shared/WebHostUtilities/WebHostUtilities.cs
diff --git a/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj b/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj
index d2f0f67f4ad0..8fe86dbf48c5 100644
--- a/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj
+++ b/src/StaticAssets/test/Microsoft.AspNetCore.StaticAssets.Tests.csproj
@@ -8,6 +8,7 @@
+
diff --git a/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj b/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj
index 91b47f770ee6..ef8b345c2206 100644
--- a/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj
+++ b/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj
@@ -16,6 +16,7 @@
+