diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b887595b1..da06a2518 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -224,6 +224,7 @@ const config: UserConfig = { {text: 'Json', link: '/guide/http/json'}, {text: 'Routing', link: '/guide/http/routing'}, {text: 'Authentication and Authorization', link: '/guide/http/security'}, + {text: 'Antiforgery / CSRF Protection', link: '/guide/http/antiforgery'}, {text: 'Working with Querystring', link: '/guide/http/querystring'}, {text: 'Headers', link: '/guide/http/headers'}, {text: 'HTTP Form Data', link: '/guide/http/forms'}, diff --git a/docs/guide/http/antiforgery.md b/docs/guide/http/antiforgery.md new file mode 100644 index 000000000..3cb6acf56 --- /dev/null +++ b/docs/guide/http/antiforgery.md @@ -0,0 +1,109 @@ +# Antiforgery / CSRF Protection + +Wolverine.HTTP integrates with ASP.NET Core's built-in [antiforgery middleware](https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery) to protect form-based endpoints from Cross-Site Request Forgery (CSRF) attacks. + +## How It Works + +When a Wolverine HTTP endpoint uses form data binding (via `[FromForm]` or file uploads), Wolverine automatically adds `IAntiforgeryMetadata` to the endpoint's metadata with `RequiresValidation = true`. ASP.NET Core's antiforgery middleware then validates the antiforgery token on incoming requests to those endpoints. + +## Setup + +To enable antiforgery protection, register the antiforgery services and middleware in your ASP.NET Core application: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +// Add antiforgery services +builder.Services.AddAntiforgery(); + +var app = builder.Build(); + +// Add the antiforgery middleware BEFORE routing +app.UseAntiforgery(); + +app.MapWolverineEndpoints(); +app.Run(); +``` + +## Automatic Protection for Form Endpoints + +Any Wolverine HTTP endpoint that binds form data automatically requires antiforgery token validation. No additional configuration is needed: + + + +```cs +// Antiforgery validation is automatic for [FromForm] endpoints +[WolverinePost("/api/form/contact")] +public static string SubmitContactForm([FromForm] string name, [FromForm] string email) +{ + return $"Received from {name} ({email})"; +} +``` +snippet source + + +## Opting Out with [DisableAntiforgery] + +For endpoints that receive form data but should not require antiforgery validation (such as webhook receivers or API endpoints called by external services), use the `[DisableAntiforgery]` attribute: + + + +```cs +// Opt out of antiforgery validation +[DisableAntiforgery] +[WolverinePost("/api/form/webhook")] +public static string WebhookReceiver([FromForm] string payload) +{ + return $"Processed: {payload}"; +} +``` +snippet source + + +The `[DisableAntiforgery]` attribute can also be applied at the class level to disable antiforgery for all endpoints in that class. + +## Opting In with [ValidateAntiforgery] + +For non-form endpoints that should require antiforgery validation (such as sensitive JSON API endpoints), use the `[ValidateAntiforgery]` attribute: + + + +```cs +// Opt in to antiforgery validation for non-form endpoints +[ValidateAntiforgery] +[WolverinePost("/api/secure/action")] +public static string SecureAction(SecureCommand command) +{ + return $"Executed: {command.Action}"; +} +``` +snippet source + + +## Global Configuration + +To require antiforgery validation on all Wolverine HTTP endpoints regardless of whether they use form binding: + +```csharp +app.MapWolverineEndpoints(opts => +{ + opts.RequireAntiforgeryOnAll(); +}); +``` + +Individual endpoints can still opt out using `[DisableAntiforgery]`. + +## Summary + +| Scenario | Behavior | +|----------|----------| +| `[FromForm]` parameter | Antiforgery required automatically | +| File upload (`IFormFile`) | Antiforgery required automatically | +| JSON body (no form data) | No antiforgery by default | +| `[DisableAntiforgery]` on method or class | Antiforgery explicitly disabled | +| `[ValidateAntiforgery]` on method or class | Antiforgery explicitly required | +| `RequireAntiforgeryOnAll()` | Antiforgery required on all endpoints | + +::: info +This feature relies entirely on ASP.NET Core's built-in antiforgery infrastructure. Wolverine simply sets the appropriate `IAntiforgeryMetadata` on endpoints so the middleware knows which endpoints to protect. No additional NuGet packages are required. +::: diff --git a/src/Http/Wolverine.Http.Tests/Antiforgery/AntiforgeryMetadataTests.cs b/src/Http/Wolverine.Http.Tests/Antiforgery/AntiforgeryMetadataTests.cs new file mode 100644 index 000000000..b89fff2f5 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/Antiforgery/AntiforgeryMetadataTests.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Mvc; +using Shouldly; +using WolverineWebApi.Antiforgery; + +namespace Wolverine.Http.Tests.Antiforgery; + +public class AntiforgeryMetadataTests +{ + [Fact] + public void form_endpoint_automatically_gets_antiforgery_metadata() + { + var chain = HttpChain.ChainFor(x => x.PostForm("")); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var antiforgeryMeta = endpoint.Metadata.GetMetadata(); + antiforgeryMeta.ShouldNotBeNull(); + antiforgeryMeta.RequiresValidation.ShouldBeTrue(); + } + + [Fact] + public void non_form_endpoint_does_not_get_antiforgery_metadata() + { + var chain = HttpChain.ChainFor(x => x.PostJson(null!)); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var antiforgeryMeta = endpoint.Metadata.GetMetadata(); + antiforgeryMeta.ShouldBeNull(); + } + + [Fact] + public void disable_antiforgery_on_form_endpoint_suppresses_metadata() + { + var chain = HttpChain.ChainFor(x => x.PostFormDisabled("")); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var antiforgeryMeta = endpoint.Metadata.GetMetadata(); + antiforgeryMeta.ShouldNotBeNull(); + antiforgeryMeta.RequiresValidation.ShouldBeFalse(); + } + + [Fact] + public void validate_antiforgery_on_non_form_endpoint_adds_metadata() + { + var chain = HttpChain.ChainFor(x => x.PostJsonRequired(null!)); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var antiforgeryMeta = endpoint.Metadata.GetMetadata(); + antiforgeryMeta.ShouldNotBeNull(); + antiforgeryMeta.RequiresValidation.ShouldBeTrue(); + } + + [Fact] + public void disable_antiforgery_on_class_applies_to_form_endpoints() + { + var chain = HttpChain.ChainFor(x => x.PostForm("")); + var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy); + + var antiforgeryMeta = endpoint.Metadata.GetMetadata(); + antiforgeryMeta.ShouldNotBeNull(); + antiforgeryMeta.RequiresValidation.ShouldBeFalse(); + } +} diff --git a/src/Http/Wolverine.Http/Antiforgery/DisableAntiforgeryAttribute.cs b/src/Http/Wolverine.Http/Antiforgery/DisableAntiforgeryAttribute.cs new file mode 100644 index 000000000..b61f50edf --- /dev/null +++ b/src/Http/Wolverine.Http/Antiforgery/DisableAntiforgeryAttribute.cs @@ -0,0 +1,9 @@ +namespace Wolverine.Http; + +/// +/// Disables antiforgery token validation for this endpoint, even if it uses form binding. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class DisableAntiforgeryAttribute : Attribute +{ +} diff --git a/src/Http/Wolverine.Http/Antiforgery/ValidateAntiforgeryAttribute.cs b/src/Http/Wolverine.Http/Antiforgery/ValidateAntiforgeryAttribute.cs new file mode 100644 index 000000000..e9d3927d2 --- /dev/null +++ b/src/Http/Wolverine.Http/Antiforgery/ValidateAntiforgeryAttribute.cs @@ -0,0 +1,9 @@ +namespace Wolverine.Http; + +/// +/// Requires antiforgery token validation for this endpoint, even if it does not use form binding. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class ValidateAntiforgeryAttribute : Attribute +{ +} diff --git a/src/Http/Wolverine.Http/Antiforgery/WolverineAntiforgeryMetadata.cs b/src/Http/Wolverine.Http/Antiforgery/WolverineAntiforgeryMetadata.cs new file mode 100644 index 000000000..94a3c03b2 --- /dev/null +++ b/src/Http/Wolverine.Http/Antiforgery/WolverineAntiforgeryMetadata.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Antiforgery; + +namespace Wolverine.Http.Antiforgery; + +internal class WolverineAntiforgeryMetadata : IAntiforgeryMetadata +{ + public static readonly WolverineAntiforgeryMetadata Required = new(true); + public static readonly WolverineAntiforgeryMetadata NotRequired = new(false); + + private WolverineAntiforgeryMetadata(bool requiresValidation) + { + RequiresValidation = requiresValidation; + } + + public bool RequiresValidation { get; } +} diff --git a/src/Http/Wolverine.Http/HttpChain.cs b/src/Http/Wolverine.Http/HttpChain.cs index 4b094de06..5a75cf0b4 100644 --- a/src/Http/Wolverine.Http/HttpChain.cs +++ b/src/Http/Wolverine.Http/HttpChain.cs @@ -8,6 +8,7 @@ using JasperFx.CodeGeneration.Model; using JasperFx.CodeGeneration.Services; using JasperFx.Core; +using Wolverine.Http.Antiforgery; using JasperFx.Core.Reflection; using JasperFx.Descriptors; using Microsoft.AspNetCore.Builder; @@ -404,10 +405,36 @@ private void applyMetadata() Metadata.Accepts(typeof(IFormFile), true, "application/x-www-form-urlencoded", "multipart/form-data"); } + applyAntiforgeryMetadata(); + foreach (var attribute in Method.HandlerType.GetCustomAttributes()) Metadata.WithMetadata(attribute); foreach (var attribute in Method.Method.GetCustomAttributes()) Metadata.WithMetadata(attribute); } + private void applyAntiforgeryMetadata() + { + // Check for explicit opt-out via [DisableAntiforgery] on method or class + if (Method.Method.HasAttribute() || + Method.HandlerType.HasAttribute()) + { + Metadata.WithMetadata(WolverineAntiforgeryMetadata.NotRequired); + return; + } + + // Check for explicit opt-in via [ValidateAntiforgery] on method or class + if (Method.Method.HasAttribute() || + Method.HandlerType.HasAttribute()) + { + Metadata.WithMetadata(WolverineAntiforgeryMetadata.Required); + return; + } + + // Auto-enable for form data and file upload endpoints + if (IsFormData || FileParameters.Any()) + { + Metadata.WithMetadata(WolverineAntiforgeryMetadata.Required); + } + } public HttpElementVariable? TryFindOrCreateFormValue(ParameterInfo parameter) { diff --git a/src/Http/Wolverine.Http/WolverineHttpOptions.cs b/src/Http/Wolverine.Http/WolverineHttpOptions.cs index 214fc137e..928bf72ec 100644 --- a/src/Http/Wolverine.Http/WolverineHttpOptions.cs +++ b/src/Http/Wolverine.Http/WolverineHttpOptions.cs @@ -3,6 +3,7 @@ using JasperFx.Core; using JasperFx.Core.Reflection; using Microsoft.AspNetCore.Builder; +using Wolverine.Http.Antiforgery; using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using Wolverine.Configuration; @@ -251,6 +252,16 @@ public void RequireAuthorizeOnAll() #endregion + /// + /// Require antiforgery token validation on all Wolverine HTTP endpoints, + /// regardless of whether they use form binding. Individual endpoints can + /// opt out with . + /// + public void RequireAntiforgeryOnAll() + { + ConfigureEndpoints(e => e.WithMetadata(WolverineAntiforgeryMetadata.Required)); + } + /// /// Add a new IEndpointPolicy for the Wolverine endpoints /// diff --git a/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs b/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs new file mode 100644 index 000000000..e3becfe27 --- /dev/null +++ b/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace WolverineWebApi.Antiforgery; + +public static class AntiforgeryEndpoints +{ + #region sample_antiforgery_form_endpoint + // Antiforgery validation is automatic for [FromForm] endpoints + [WolverinePost("/api/form/contact")] + public static string SubmitContactForm([FromForm] string name, [FromForm] string email) + { + return $"Received from {name} ({email})"; + } + #endregion + + #region sample_antiforgery_disabled + // Opt out of antiforgery validation + [DisableAntiforgery] + [WolverinePost("/api/form/webhook")] + public static string WebhookReceiver([FromForm] string payload) + { + return $"Processed: {payload}"; + } + #endregion + + #region sample_antiforgery_explicit + // Opt in to antiforgery validation for non-form endpoints + [ValidateAntiforgery] + [WolverinePost("/api/secure/action")] + public static string SecureAction(SecureCommand command) + { + return $"Executed: {command.Action}"; + } + #endregion +} + +public record SecureCommand(string Action); diff --git a/src/Http/WolverineWebApi/Antiforgery/AntiforgeryTestEndpoints.cs b/src/Http/WolverineWebApi/Antiforgery/AntiforgeryTestEndpoints.cs new file mode 100644 index 000000000..2fd14192e --- /dev/null +++ b/src/Http/WolverineWebApi/Antiforgery/AntiforgeryTestEndpoints.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using Wolverine.Http; + +namespace WolverineWebApi.Antiforgery; + +public class AntiforgeryTestEndpoints +{ + [WolverinePost("/antiforgery/form")] + public string PostForm([FromForm] string name) => name; + + [WolverinePost("/antiforgery/json")] + public string PostJson(AntiforgeryDto dto) => dto.Name; + + [WolverinePost("/antiforgery/form-disabled")] + [DisableAntiforgery] + public string PostFormDisabled([FromForm] string name) => name; + + [WolverinePost("/antiforgery/json-required")] + [ValidateAntiforgery] + public string PostJsonRequired(AntiforgeryDto dto) => dto.Name; +} + +[DisableAntiforgery] +public class DisabledAntiforgeryEndpoints +{ + [WolverinePost("/antiforgery/class-disabled")] + public string PostForm([FromForm] string name) => name; +} + +public record AntiforgeryDto(string Name);