diff --git a/src/Antiforgery/src/AntiforgeryMiddleware.cs b/src/Antiforgery/src/AntiforgeryMiddleware.cs new file mode 100644 index 000000000000..1a68c43a4a64 --- /dev/null +++ b/src/Antiforgery/src/AntiforgeryMiddleware.cs @@ -0,0 +1,130 @@ +// 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.AspNetCore.Http.Abstractions.Metadata; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Antiforgery; + +internal sealed partial class AntiforgeryMiddleware +{ + private readonly IAntiforgery _antiforgery; + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public AntiforgeryMiddleware(IAntiforgery antiforgery, RequestDelegate next, ILogger logger) + { + _antiforgery = antiforgery; + _next = next; + _logger = logger; + } + + public Task Invoke(HttpContext context) + { + var endpoint = context.GetEndpoint(); + if (endpoint is null) + { + return _next(context); + } + + var antiforgeryMetadata = endpoint.Metadata.GetMetadata(); + if (antiforgeryMetadata is null) + { + Log.NoAntiforgeryMetadataFound(_logger); + return _next(context); + } + + if (antiforgeryMetadata is not IValidateAntiforgeryMetadata validateAntiforgeryMetadata) + { + Log.IgnoreAntiforgeryMetadataFound(_logger); + return _next(context); + } + + if (_antiforgery is DefaultAntiforgery defaultAntiforgery) + { + var valueTask = defaultAntiforgery.TryValidateAsync(context, validateAntiforgeryMetadata.ValidateIdempotentRequests); + if (valueTask.IsCompletedSuccessfully) + { + var (success, message) = valueTask.GetAwaiter().GetResult(); + if (success) + { + Log.AntiforgeryValidationSucceeded(_logger); + return _next(context); + } + else + { + Log.AntiforgeryValidationFailed(_logger, message); + return WriteAntiforgeryInvalidResponseAsync(context, message); + } + } + + return TryValidateAsyncAwaited(context, valueTask); + } + else + { + return ValidateNonDefaultAntiforgery(context); + } + } + + private async Task TryValidateAsyncAwaited(HttpContext context, ValueTask<(bool success, string? message)> tryValidateTask) + { + var (success, message) = await tryValidateTask; + if (success) + { + Log.AntiforgeryValidationSucceeded(_logger); + await _next(context); + } + else + { + Log.AntiforgeryValidationFailed(_logger, message); + await context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Antiforgery validation failed", + Detail = message, + }); + } + } + + private async Task ValidateNonDefaultAntiforgery(HttpContext context) + { + if (await _antiforgery.IsRequestValidAsync(context)) + { + Log.AntiforgeryValidationSucceeded(_logger); + await _next(context); + } + else + { + Log.AntiforgeryValidationFailed(_logger, message: null); + await WriteAntiforgeryInvalidResponseAsync(context, message: null); + } + } + + private static Task WriteAntiforgeryInvalidResponseAsync(HttpContext context, string? message) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return context.Response.WriteAsJsonAsync(new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Antiforgery validation failed", + Detail = message, + }); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "No antiforgery metadata found on the endpoint.", EventName = "NoAntiforgeryMetadataFound")] + public static partial void NoAntiforgeryMetadataFound(ILogger logger); + + [LoggerMessage(2, LogLevel.Debug, $"Antiforgery validation suppressed on endpoint because {nameof(IValidateAntiforgeryMetadata)} was not found.", EventName = "IgnoreAntiforgeryMetadataFound")] + public static partial void IgnoreAntiforgeryMetadataFound(ILogger logger); + + [LoggerMessage(3, LogLevel.Debug, "Antiforgery validation completed successfully.", EventName = "AntiforgeryValidationSucceeded")] + public static partial void AntiforgeryValidationSucceeded(ILogger logger); + + [LoggerMessage(4, LogLevel.Debug, "Antiforgery validation failed with message '{message}'.", EventName = "AntiforgeryValidationFailed")] + public static partial void AntiforgeryValidationFailed(ILogger logger, string? message); + } +} diff --git a/src/Antiforgery/src/AntiforgeryMiddlewareExtensions.cs b/src/Antiforgery/src/AntiforgeryMiddlewareExtensions.cs new file mode 100644 index 000000000000..c496abfc6c57 --- /dev/null +++ b/src/Antiforgery/src/AntiforgeryMiddlewareExtensions.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// The extensions for adding Antiforgery middleware support. +/// +public static class AntiforgeryMiddlewareExtensions +{ + /// + /// Adds the Antiforgery middleware to the middleware pipeline. + /// + /// The . + /// A reference to the after the operation has completed. + public static IApplicationBuilder UseAntiforgery(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Antiforgery/src/Internal/DefaultAntiforgery.cs b/src/Antiforgery/src/Internal/DefaultAntiforgery.cs index 0558641814aa..76f2fd57817f 100644 --- a/src/Antiforgery/src/Internal/DefaultAntiforgery.cs +++ b/src/Antiforgery/src/Internal/DefaultAntiforgery.cs @@ -1,10 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -100,35 +98,43 @@ public async Task IsRequestValidAsync(HttpContext httpContext) throw new ArgumentNullException(nameof(httpContext)); } + var (result, _) = await TryValidateAsync(httpContext, validateIdempotentRequests: false); + return result; + } + + internal async ValueTask<(bool success, string? errorMessage)> TryValidateAsync(HttpContext httpContext, bool validateIdempotentRequests) + { CheckSSLConfig(httpContext); var method = httpContext.Request.Method; - if (HttpMethods.IsGet(method) || + if ( + !validateIdempotentRequests && + (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method) || - HttpMethods.IsTrace(method)) + HttpMethods.IsTrace(method))) { // Validation not needed for these request types. - return true; + return (true, null); } var tokens = await _tokenStore.GetRequestTokensAsync(httpContext); if (tokens.CookieToken == null) { _logger.MissingCookieToken(_options.Cookie.Name); - return false; + return (false, "Missing cookie token"); } if (tokens.RequestToken == null) { _logger.MissingRequestToken(_options.FormFieldName, _options.HeaderName); - return false; + return (false, "Antiforgery token could not be found in the HTTP request."); } // Extract cookie & request tokens if (!TryDeserializeTokens(httpContext, tokens, out var deserializedCookieToken, out var deserializedRequestToken)) { - return false; + return (false, "Unable to deserialize antiforgery tokens"); } // Validate @@ -147,7 +153,7 @@ public async Task IsRequestValidAsync(HttpContext httpContext) _logger.ValidationFailed(message!); } - return result; + return (result, message); } /// diff --git a/src/Antiforgery/src/PublicAPI.Unshipped.txt b/src/Antiforgery/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..d10011312667 100644 --- a/src/Antiforgery/src/PublicAPI.Unshipped.txt +++ b/src/Antiforgery/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Builder.AntiforgeryMiddlewareExtensions +static Microsoft.AspNetCore.Builder.AntiforgeryMiddlewareExtensions.UseAntiforgery(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! diff --git a/src/Http/Http.Abstractions/src/Metadata/IAntiforgeryMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IAntiforgeryMetadata.cs new file mode 100644 index 000000000000..cd4462cf6d45 --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IAntiforgeryMetadata.cs @@ -0,0 +1,11 @@ +// 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.Http.Abstractions.Metadata; + +/// +/// A marker interface which can be used to identify Antiforgery metadata. +/// +public interface IAntiforgeryMetadata +{ +} diff --git a/src/Http/Http.Abstractions/src/Metadata/IValidateAntiforgeryMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IValidateAntiforgeryMetadata.cs new file mode 100644 index 000000000000..b88cc2eb6e2e --- /dev/null +++ b/src/Http/Http.Abstractions/src/Metadata/IValidateAntiforgeryMetadata.cs @@ -0,0 +1,15 @@ +// 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.Http.Abstractions.Metadata; + +/// +/// A marker interface which can be used to identify a resource with Antiforgery validation enabled. +/// +public interface IValidateAntiforgeryMetadata : IAntiforgeryMetadata +{ + /// + /// Gets a value that determines if idempotent HTTP methods (GET, HEAD, OPTIONS and TRACE) are validated. + /// + bool ValidateIdempotentRequests { get; } +} diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt index 244ddbf827dc..c591e4e994e0 100644 --- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable *REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string! +Microsoft.AspNetCore.Http.Abstractions.Metadata.IAntiforgeryMetadata +Microsoft.AspNetCore.Http.Abstractions.Metadata.IValidateAntiforgeryMetadata +Microsoft.AspNetCore.Http.Abstractions.Metadata.IValidateAntiforgeryMetadata.ValidateIdempotentRequests.get -> bool abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string? diff --git a/src/Mvc/Mvc.Core/src/ApplicationModels/AntiforgeryApplicationModelProvider.cs b/src/Mvc/Mvc.Core/src/ApplicationModels/AntiforgeryApplicationModelProvider.cs new file mode 100644 index 000000000000..f1bf092a293f --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ApplicationModels/AntiforgeryApplicationModelProvider.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Microsoft.AspNetCore.Http.Abstractions.Metadata; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Mvc.ApplicationModels; + +/// +/// An that removes antiforgery filters that appears as endpoint metadata. +/// +internal sealed class AntiforgeryApplicationModelProvider : IApplicationModelProvider +{ + private readonly MvcOptions _mvcOptions; + + public AntiforgeryApplicationModelProvider(IOptions mvcOptions) + { + _mvcOptions = mvcOptions.Value; + } + + // Run late in the pipeline so that we can pick up user configured AntiforgeryTokens. + public int Order { get; } = 1000; + + public void OnProvidersExecuted(ApplicationModelProviderContext context) + { + } + + public void OnProvidersExecuting(ApplicationModelProviderContext context) + { + if (!_mvcOptions.EnableEndpointRouting) + { + return; + } + + foreach (var controller in context.Result.Controllers) + { + RemoveAntiforgeryFilters(controller.Filters, controller.Selectors); + + foreach (var action in controller.Actions) + { + RemoveAntiforgeryFilters(action.Filters, action.Selectors); + } + } + } + + private static void RemoveAntiforgeryFilters(IList filters, IList selectorModels) + { + for (var i = filters.Count - 1; i >= 0; i--) + { + if (filters[i] is IAntiforgeryMetadata antiforgeryMetadata && + selectorModels.All(s => s.EndpointMetadata.Contains(antiforgeryMetadata))) + { + filters.RemoveAt(i); + } + } + } +} diff --git a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs index efd04ce49ef8..8f3b5ca00024 100644 --- a/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs +++ b/src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs @@ -160,6 +160,8 @@ internal static void AddMvcCoreServices(IServiceCollection services) services.TryAddSingleton(); services.TryAddEnumerable( ServiceDescriptor.Transient()); + services.TryAddEnumerable( + ServiceDescriptor.Transient()); services.TryAddEnumerable( ServiceDescriptor.Transient()); services.TryAddEnumerable( diff --git a/src/Mvc/Mvc.ViewFeatures/src/AutoValidateAntiforgeryTokenAttribute.cs b/src/Mvc/Mvc.ViewFeatures/src/AutoValidateAntiforgeryTokenAttribute.cs index a8d6a0a33651..459236ce81b7 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/AutoValidateAntiforgeryTokenAttribute.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/AutoValidateAntiforgeryTokenAttribute.cs @@ -4,6 +4,7 @@ #nullable enable using System; +using Microsoft.AspNetCore.Http.Abstractions.Metadata; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ViewFeatures.Filters; using Microsoft.Extensions.DependencyInjection; @@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Mvc; /// a controller or action. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public class AutoValidateAntiforgeryTokenAttribute : Attribute, IFilterFactory, IOrderedFilter +public class AutoValidateAntiforgeryTokenAttribute : Attribute, IFilterFactory, IOrderedFilter, IValidateAntiforgeryMetadata { /// /// Gets the order value for determining the order of execution of filters. Filters execute in @@ -44,6 +45,8 @@ public class AutoValidateAntiforgeryTokenAttribute : Attribute, IFilterFactory, /// public bool IsReusable => true; + bool IValidateAntiforgeryMetadata.ValidateIdempotentRequests => false; + /// public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) { diff --git a/src/Mvc/Mvc.ViewFeatures/src/Filters/AutoValidateAntiforgeryTokenAuthorizationFilter.cs b/src/Mvc/Mvc.ViewFeatures/src/Filters/AutoValidateAntiforgeryTokenAuthorizationFilter.cs index 62a77ac66239..486de48368cc 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Filters/AutoValidateAntiforgeryTokenAuthorizationFilter.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Filters/AutoValidateAntiforgeryTokenAuthorizationFilter.cs @@ -6,13 +6,14 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Filters; internal class AutoValidateAntiforgeryTokenAuthorizationFilter : ValidateAntiforgeryTokenAuthorizationFilter { - public AutoValidateAntiforgeryTokenAuthorizationFilter(IAntiforgery antiforgery, ILoggerFactory loggerFactory) - : base(antiforgery, loggerFactory) + public AutoValidateAntiforgeryTokenAuthorizationFilter(IAntiforgery antiforgery, ILoggerFactory loggerFactory, IOptions mvcOptions) + : base(antiforgery, loggerFactory, mvcOptions) { } diff --git a/src/Mvc/Mvc.ViewFeatures/src/Filters/ValidateAntiforgeryTokenAuthorizationFilter.cs b/src/Mvc/Mvc.ViewFeatures/src/Filters/ValidateAntiforgeryTokenAuthorizationFilter.cs index 151d84278746..510bbf204993 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/Filters/ValidateAntiforgeryTokenAuthorizationFilter.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/Filters/ValidateAntiforgeryTokenAuthorizationFilter.cs @@ -1,11 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Threading.Tasks; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Filters; @@ -13,8 +12,9 @@ internal class ValidateAntiforgeryTokenAuthorizationFilter : IAsyncAuthorization { private readonly IAntiforgery _antiforgery; private readonly ILogger _logger; + private readonly MvcOptions _mvcOptions; - public ValidateAntiforgeryTokenAuthorizationFilter(IAntiforgery antiforgery, ILoggerFactory loggerFactory) + public ValidateAntiforgeryTokenAuthorizationFilter(IAntiforgery antiforgery, ILoggerFactory loggerFactory, IOptions mvcOptions) { if (antiforgery == null) { @@ -23,9 +23,20 @@ public ValidateAntiforgeryTokenAuthorizationFilter(IAntiforgery antiforgery, ILo _antiforgery = antiforgery; _logger = loggerFactory.CreateLogger(GetType()); + _mvcOptions = mvcOptions.Value; } - public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + public Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + if (_mvcOptions.EnableEndpointRouting) + { + return Task.CompletedTask; + } + + return OnAuthorizationCoreAsync(context); + } + + private async Task OnAuthorizationCoreAsync(AuthorizationFilterContext context) { if (context == null) { diff --git a/src/Mvc/Mvc.ViewFeatures/src/ValidateAntiForgeryTokenAttribute.cs b/src/Mvc/Mvc.ViewFeatures/src/ValidateAntiForgeryTokenAttribute.cs index 3fae350b66bd..a424a519faf2 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/ValidateAntiForgeryTokenAttribute.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/ValidateAntiForgeryTokenAttribute.cs @@ -4,6 +4,7 @@ #nullable enable using System; +using Microsoft.AspNetCore.Http.Abstractions.Metadata; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ViewFeatures.Filters; using Microsoft.Extensions.DependencyInjection; @@ -20,7 +21,7 @@ namespace Microsoft.AspNetCore.Mvc; /// attacks. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] -public class ValidateAntiForgeryTokenAttribute : Attribute, IFilterFactory, IOrderedFilter +public class ValidateAntiForgeryTokenAttribute : Attribute, IFilterFactory, IOrderedFilter, IValidateAntiforgeryMetadata { /// /// Gets the order value for determining the order of execution of filters. Filters execute in @@ -43,6 +44,8 @@ public class ValidateAntiForgeryTokenAttribute : Attribute, IFilterFactory, IOrd /// public bool IsReusable => true; + bool IValidateAntiforgeryMetadata.ValidateIdempotentRequests => true; + /// public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) { diff --git a/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs b/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs index f5d5c9df5662..5c3a3a080aee 100644 --- a/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs +++ b/src/Mvc/samples/MvcSandbox/Controllers/HomeController.cs @@ -10,8 +10,23 @@ public class HomeController : Controller [ModelBinder] public string Id { get; set; } + [HttpGet] public IActionResult Index() { return View(); } + + [ValidateAntiForgeryToken] + [HttpPost] + public IActionResult Index(Person person) + { + return View(); + } +} + +public class Person +{ + public string Name { get; set; } + + public int Age { get; set; } } diff --git a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj index b37cc9ac046e..722e50af331f 100644 --- a/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj +++ b/src/Mvc/samples/MvcSandbox/MvcSandbox.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -8,6 +8,7 @@ + diff --git a/src/Mvc/samples/MvcSandbox/Startup.cs b/src/Mvc/samples/MvcSandbox/Startup.cs index b0e53bcade1e..05476dd0b444 100644 --- a/src/Mvc/samples/MvcSandbox/Startup.cs +++ b/src/Mvc/samples/MvcSandbox/Startup.cs @@ -24,7 +24,10 @@ public void Configure(IApplicationBuilder app) app.UseDeveloperExceptionPage(); app.UseStaticFiles(); + app.UseRouting(); + app.UseAntiforgery(); + app.UseEndpoints(builder => { builder.MapControllers(); diff --git a/src/Mvc/samples/MvcSandbox/Views/Home/Index.cshtml b/src/Mvc/samples/MvcSandbox/Views/Home/Index.cshtml index b1c5cc559876..4167a86edba2 100644 --- a/src/Mvc/samples/MvcSandbox/Views/Home/Index.cshtml +++ b/src/Mvc/samples/MvcSandbox/Views/Home/Index.cshtml @@ -1,4 +1,5 @@ -@{ +@model MvcSandbox.Controllers.Person +@{ ViewData["Title"] = "Home Page"; } @@ -6,3 +7,11 @@

Sandbox

This sandbox should give you a quick view of a basic MVC application.

+ + +
+
+ + + +