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 @@ +