Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ const config: UserConfig<DefaultTheme.Config> = {
{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'},
Expand Down
109 changes: 109 additions & 0 deletions docs/guide/http/antiforgery.md
Original file line number Diff line number Diff line change
@@ -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:

<!-- snippet: sample_antiforgery_form_endpoint -->
<a id='snippet-sample_antiforgery_form_endpoint'></a>
```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})";
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs#L10-L19' title='Snippet source file'>snippet source</a></sup>
<!-- endSnippet -->

## 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:

<!-- snippet: sample_antiforgery_disabled -->
<a id='snippet-sample_antiforgery_disabled'></a>
```cs
// Opt out of antiforgery validation
[DisableAntiforgery]
[WolverinePost("/api/form/webhook")]
public static string WebhookReceiver([FromForm] string payload)
{
return $"Processed: {payload}";
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs#L21-L31' title='Snippet source file'>snippet source</a></sup>
<!-- endSnippet -->

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:

<!-- snippet: sample_antiforgery_explicit -->
<a id='snippet-sample_antiforgery_explicit'></a>
```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}";
}
```
<sup><a href='https://github.com/JasperFx/wolverine/blob/main/src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs#L33-L43' title='Snippet source file'>snippet source</a></sup>
<!-- endSnippet -->

## 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.
:::
Original file line number Diff line number Diff line change
@@ -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<AntiforgeryTestEndpoints>(x => x.PostForm(""));
var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy);

var antiforgeryMeta = endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>();
antiforgeryMeta.ShouldNotBeNull();
antiforgeryMeta.RequiresValidation.ShouldBeTrue();
}

[Fact]
public void non_form_endpoint_does_not_get_antiforgery_metadata()
{
var chain = HttpChain.ChainFor<AntiforgeryTestEndpoints>(x => x.PostJson(null!));
var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy);

var antiforgeryMeta = endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>();
antiforgeryMeta.ShouldBeNull();
}

[Fact]
public void disable_antiforgery_on_form_endpoint_suppresses_metadata()
{
var chain = HttpChain.ChainFor<AntiforgeryTestEndpoints>(x => x.PostFormDisabled(""));
var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy);

var antiforgeryMeta = endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>();
antiforgeryMeta.ShouldNotBeNull();
antiforgeryMeta.RequiresValidation.ShouldBeFalse();
}

[Fact]
public void validate_antiforgery_on_non_form_endpoint_adds_metadata()
{
var chain = HttpChain.ChainFor<AntiforgeryTestEndpoints>(x => x.PostJsonRequired(null!));
var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy);

var antiforgeryMeta = endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>();
antiforgeryMeta.ShouldNotBeNull();
antiforgeryMeta.RequiresValidation.ShouldBeTrue();
}

[Fact]
public void disable_antiforgery_on_class_applies_to_form_endpoints()
{
var chain = HttpChain.ChainFor<DisabledAntiforgeryEndpoints>(x => x.PostForm(""));
var endpoint = chain.BuildEndpoint(RouteWarmup.Lazy);

var antiforgeryMeta = endpoint.Metadata.GetMetadata<IAntiforgeryMetadata>();
antiforgeryMeta.ShouldNotBeNull();
antiforgeryMeta.RequiresValidation.ShouldBeFalse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Wolverine.Http;

/// <summary>
/// Disables antiforgery token validation for this endpoint, even if it uses form binding.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class DisableAntiforgeryAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Wolverine.Http;

/// <summary>
/// Requires antiforgery token validation for this endpoint, even if it does not use form binding.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class ValidateAntiforgeryAttribute : Attribute
{
}
Original file line number Diff line number Diff line change
@@ -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; }
}
27 changes: 27 additions & 0 deletions src/Http/Wolverine.Http/HttpChain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DisableAntiforgeryAttribute>() ||
Method.HandlerType.HasAttribute<DisableAntiforgeryAttribute>())
{
Metadata.WithMetadata(WolverineAntiforgeryMetadata.NotRequired);
return;
}

// Check for explicit opt-in via [ValidateAntiforgery] on method or class
if (Method.Method.HasAttribute<ValidateAntiforgeryAttribute>() ||
Method.HandlerType.HasAttribute<ValidateAntiforgeryAttribute>())
{
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)
{
Expand Down
11 changes: 11 additions & 0 deletions src/Http/Wolverine.Http/WolverineHttpOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -251,6 +252,16 @@ public void RequireAuthorizeOnAll()

#endregion

/// <summary>
/// Require antiforgery token validation on all Wolverine HTTP endpoints,
/// regardless of whether they use form binding. Individual endpoints can
/// opt out with <see cref="DisableAntiforgeryAttribute"/>.
/// </summary>
public void RequireAntiforgeryOnAll()
{
ConfigureEndpoints(e => e.WithMetadata(WolverineAntiforgeryMetadata.Required));
}

/// <summary>
/// Add a new IEndpointPolicy for the Wolverine endpoints
/// </summary>
Expand Down
38 changes: 38 additions & 0 deletions src/Http/WolverineWebApi/Antiforgery/AntiforgeryEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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);
30 changes: 30 additions & 0 deletions src/Http/WolverineWebApi/Antiforgery/AntiforgeryTestEndpoints.cs
Original file line number Diff line number Diff line change
@@ -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);
Loading