From dotnet-skills
Validating HTTP request inputs. .NET 10 AddValidation, FluentValidation, ProblemDetails.
npx claudepluginhub wshaddix/dotnet-skillsThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Comprehensive input validation patterns for .NET APIs. Covers the .NET 10 built-in validation system, FluentValidation for complex business rules, Data Annotations for simple models, endpoint filters for Minimal API integration, ProblemDetails error responses, and security-focused validation techniques.
Scope boundary: This skill owns practical validation framework guidance -- when to use each framework, how to configure and integrate them, and security-focused input handling tips. OWASP security principles (injection categories, threat modeling) -- see [skill:dotnet-security-owasp]. Architectural validation strategy (where validation fits in clean architecture, vertical slices) -- see [skill:dotnet-architecture-patterns]. Options pattern validation with ValidateDataAnnotations() -- see [skill:dotnet-csharp-configuration].
Out of scope: Blazor form validation (EditForm, DataAnnotationsValidator) -- see [skill:dotnet-blazor-auth]. OWASP injection prevention principles -- see [skill:dotnet-security-owasp]. Architectural patterns for validation placement -- see [skill:dotnet-architecture-patterns]. Options pattern ValidateDataAnnotations -- see [skill:dotnet-csharp-configuration].
Cross-references: [skill:dotnet-security-owasp] for OWASP injection prevention, [skill:dotnet-architecture-patterns] for architectural validation strategy, [skill:dotnet-minimal-apis] for Minimal API pipeline integration, [skill:dotnet-csharp-configuration] for Options pattern validation.
Choose the validation framework based on project requirements:
AddValidation) -- default for new .NET 10+ projects. Source-generator-based, AOT-compatible, auto-discovers types from Minimal API handlers. Best for: greenfield projects targeting .NET 10+.[Required], [Range]). Widely understood, works with MVC model binding and IValidatableObject for cross-property checks. Best for: simple DTOs, shared models.General guidance: prefer .NET 10 built-in validation for new projects. Use FluentValidation when rules outgrow annotations. Do not mix multiple frameworks in the same request DTO -- pick one per model type and stay consistent.
.NET 10 introduces Microsoft.Extensions.Validation with source-generator-based validation that integrates directly into the Minimal API pipeline. It auto-discovers validatable types from endpoint handler parameters and runs validation via an endpoint filter.
// <PackageReference Include="Microsoft.Extensions.Validation" Version="10.*" />
builder.Services.AddValidation();
var app = builder.Build();
// Validation runs automatically via endpoint filter for Minimal API handlers
AddValidation() scans for types annotated with [ValidatableType] and generates validation logic at compile time using source generators, ensuring Native AOT compatibility.
[ValidatableType]
public partial class CreateProductRequest
{
[Required]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; set; }
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
[Required]
[RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$", ErrorMessage = "SKU format: AA-0000")]
public required string Sku { get; set; }
}
The partial keyword is required because the source generator emits validation logic into the same type. The [ValidatableType] attribute triggers code generation at compile time -- no reflection at runtime.
[ValidatableType] classes and emits IValidatableObject-like validation logic.AddValidation() registers an endpoint filter that inspects Minimal API handler parameters.ValidationProblem response automatically.Gotcha: AddValidation() integrates with Minimal APIs via endpoint filters. MVC controllers use their own model validation pipeline and do not participate in this filter-based system. For controllers, Data Annotations and ModelState.IsValid remain the standard approach.
FluentValidation provides a fluent API for building strongly-typed validation rules. It excels at complex business validation with cross-property rules, conditional logic, and database-dependent checks.
// <PackageReference Include="FluentValidation" Version="11.*" />
// <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.OrderDate)
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("Order date cannot be in the future");
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Order must have at least one line item");
RuleForEach(x => x.Lines)
.ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
// Conditional rule
When(x => x.ShippingMethod == ShippingMethod.Express, () =>
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.WithMessage("Express shipping requires an address");
});
}
}
// Registers all AbstractValidator<T> implementations from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);
FluentValidation's ASP.NET pipeline auto-validation is deprecated. Use manual validation in endpoint handlers or endpoint filters instead:
app.MapPost("/api/orders", async (
CreateOrderRequest request,
IValidator<CreateOrderRequest> validator,
AppDbContext db) =>
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
return TypedResults.ValidationProblem(result.ToDictionary());
}
var order = new Order { CustomerId = request.CustomerId };
db.Orders.Add(order);
await db.SaveChangesAsync();
return TypedResults.Created($"/api/orders/{order.Id}", order);
});
For reusable validation across multiple endpoints, create a generic endpoint filter (see also [skill:dotnet-minimal-apis] for filter pipeline details):
public sealed class FluentValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return TypedResults.BadRequest("Request body is required");
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
return await next(context);
}
}
// Apply to endpoints
products.MapPost("/", CreateProduct)
.AddEndpointFilter<FluentValidationFilter<CreateProductDto>>();
Gotcha: Do not use the deprecated FluentValidation.AspNetCore auto-validation pipeline. It was removed in FluentValidation 11. Use manual validation or endpoint filters as shown above.
Data Annotations provide declarative validation through attributes. They work with MVC model binding, Minimal API binding, and the .NET 10 AddValidation() source generator.
public sealed class UpdateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(200, MinimumLength = 1)]
public required string Name { get; set; }
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between {1} and {2}")]
public decimal Price { get; set; }
[RegularExpression(@"^[A-Z]{2,4}-\d{4,8}$")]
public string? Sku { get; set; }
[EmailAddress]
public string? ContactEmail { get; set; }
[Url]
public string? WebsiteUrl { get; set; }
[Phone]
public string? SupportPhone { get; set; }
}
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is DateOnly date && date <= DateOnly.FromDateTime(DateTime.UtcNow))
{
return new ValidationResult(
ErrorMessage ?? "Date must be in the future",
new[] { context.MemberName! });
}
return ValidationResult.Success;
}
}
// Usage
public sealed class CreateEventDto
{
[Required]
[StringLength(200)]
public required string Title { get; set; }
[FutureDate(ErrorMessage = "Event date must be in the future")]
public DateOnly EventDate { get; set; }
}
public sealed class DateRangeDto : IValidatableObject
{
[Required]
public DateOnly StartDate { get; set; }
[Required]
public DateOnly EndDate { get; set; }
[Range(1, 365)]
public int MaxDays { get; set; } = 30;
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (EndDate < StartDate)
{
yield return new ValidationResult(
"End date must be after start date",
new[] { nameof(EndDate) });
}
if ((EndDate.ToDateTime(TimeOnly.MinValue) - StartDate.ToDateTime(TimeOnly.MinValue)).Days > MaxDays)
{
yield return new ValidationResult(
$"Date range cannot exceed {MaxDays} days",
new[] { nameof(StartDate), nameof(EndDate) });
}
}
}
Gotcha: Options pattern classes must use { get; set; } not { get; init; } because the configuration binder needs to mutate properties after construction. Validation attributes on init-only properties work for request DTOs but fail for options classes bound via IConfiguration. See [skill:dotnet-csharp-configuration] for Options pattern validation.
Endpoint filters integrate validation into the Minimal API request pipeline as a cross-cutting concern. Filters execute before the handler, enabling centralized validation logic.
public sealed class DataAnnotationsValidationFilter<T> : IEndpointFilter
where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
return TypedResults.BadRequest("Request body is required");
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(argument);
if (!Validator.TryValidateObject(argument, validationContext, validationResults, validateAllProperties: true))
{
var errors = validationResults
.Where(r => r.MemberNames.Any())
.GroupBy(r => r.MemberNames.First())
.ToDictionary(
g => g.Key,
g => g.Select(r => r.ErrorMessage ?? "Validation failed").ToArray());
return TypedResults.ValidationProblem(errors);
}
return await next(context);
}
}
// Apply to endpoints or route groups
products.MapPost("/", CreateProduct)
.AddEndpointFilter<DataAnnotationsValidationFilter<CreateProductDto>>();
products.MapPut("/{id:int}", UpdateProduct)
.AddEndpointFilter<DataAnnotationsValidationFilter<UpdateProductDto>>();
Apply validation filters at the route group level for consistent validation across all endpoints in a group (see [skill:dotnet-minimal-apis] for route group patterns):
var orders = app.MapGroup("/api/orders")
.AddEndpointFilter<FluentValidationFilter<CreateOrderRequest>>();
Gotcha: Filter execution order matters -- first-registered filter is outermost. Register validation filters after logging but before authorization enrichment so that invalid requests are rejected early without unnecessary processing.
Use the ProblemDetails standard (RFC 9457) for consistent API error responses. ASP.NET Core has built-in support via TypedResults.ValidationProblem() and IProblemDetailsService.
// Returns HTTP 400 with RFC 9457-compliant body
app.MapPost("/api/products", async (CreateProductDto dto, IValidator<CreateProductDto> validator) =>
{
var result = await validator.ValidateAsync(dto);
if (!result.IsValid)
{
// Produces: { "type": "...", "title": "...", "status": 400, "errors": { ... } }
return TypedResults.ValidationProblem(result.ToDictionary());
}
// ... create product
return TypedResults.Created($"/api/products/{product.Id}", product);
});
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
context.ProblemDetails.Extensions["instance"] =
context.HttpContext.Request.Path.Value;
};
});
var app = builder.Build();
app.UseStatusCodePages();
app.UseExceptionHandler();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler(exceptionApp =>
{
exceptionApp.Run(async context =>
{
var problemDetailsService = context.RequestServices
.GetRequiredService<IProblemDetailsService>();
await problemDetailsService.WriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails =
{
Title = "An unexpected error occurred",
Status = StatusCodes.Status500InternalServerError,
Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1"
}
});
});
});
Gotcha: ConfigureHttpJsonOptions applies to Minimal APIs only, not MVC controllers. Validation error formatting (e.g., camelCase property names in the errors dictionary) may differ between Minimal APIs and MVC if JSON options are not configured consistently. For MVC controllers, configure via builder.Services.AddControllers().AddJsonOptions(...).
Input validation is a first line of defense against injection and abuse. These patterns complement the OWASP security principles in [skill:dotnet-security-owasp] with practical validation techniques.
Regular expressions with backtracking can be exploited to cause catastrophic performance degradation (Regular Expression Denial of Service). Always apply timeouts or use source-generated regex.
// PREFERRED: [GeneratedRegex] -- compiled at build time, AOT-compatible (.NET 7+).
// Combine with RegexOptions.NonBacktracking or a timeout for ReDoS safety.
public static partial class InputPatterns
{
[GeneratedRegex(@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$",
RegexOptions.None, matchTimeoutMilliseconds: 1000)]
public static partial Regex EmailPattern();
[GeneratedRegex(@"^[A-Z]{2,4}-\d{4,8}$")]
public static partial Regex SkuPattern();
}
// Usage in validation
if (!InputPatterns.EmailPattern().IsMatch(input))
{
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["email"] = ["Invalid email format"]
});
}
// FALLBACK: Regex with explicit timeout (when source generation is not available)
var pattern = new Regex(
@"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$",
RegexOptions.Compiled,
matchTimeout: TimeSpan.FromSeconds(1));
try
{
if (!pattern.IsMatch(input))
return TypedResults.BadRequest("Invalid format");
}
catch (RegexMatchTimeoutException)
{
return TypedResults.BadRequest("Input validation timed out");
}
Always prefer allowlist validation over denylist. Denylists are inherently incomplete because new attack vectors bypass them.
// CORRECT: Allowlist -- only permit known-good values
private static readonly FrozenSet<string> AllowedFileExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".jpg", ".jpeg", ".png", ".gif", ".webp" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public static bool IsAllowedExtension(string filename) =>
AllowedFileExtensions.Contains(Path.GetExtension(filename));
// WRONG: Denylist -- attackers find extensions not in the list
// private static readonly string[] BlockedExtensions = [".exe", ".bat", ".cmd", ".ps1"];
// Allowlist for input characters
public static bool IsValidUsername(string username) =>
username.Length is >= 3 and <= 50
&& username.All(c => char.IsLetterOrDigit(c) || c is '_' or '-');
Enforce maximum length on all user-controlled string inputs to prevent memory exhaustion and buffer-based attacks:
[ValidatableType]
public partial class SearchRequest
{
[Required]
[StringLength(200)] // Always set max length on search inputs
public required string Query { get; set; }
[Range(1, 100)]
public int PageSize { get; set; } = 20;
}
Also enforce at the Kestrel level for defense in depth:
// Configure BEFORE builder.Build()
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
options.Limits.MaxRequestHeadersTotalSize = 32 * 1024; // 32 KB
});
Validate file uploads by content type, size, and extension -- never trust the client-provided Content-Type header alone:
app.MapPost("/api/uploads", async (IFormFile file) =>
{
// 1. Validate extension (allowlist)
var extension = Path.GetExtension(file.FileName);
if (!AllowedFileExtensions.Contains(extension))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = [$"File type '{extension}' is not allowed"]
});
// 2. Validate file size
const long maxSize = 5 * 1024 * 1024; // 5 MB
if (file.Length > maxSize)
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = [$"File size exceeds {maxSize / (1024 * 1024)} MB limit"]
});
// 3. Validate content by reading magic bytes (not Content-Type header)
using var stream = file.OpenReadStream();
const int headerSize = 12; // Need 12 bytes for WebP (RIFF + WEBP)
var header = new byte[headerSize];
// Guard against files shorter than header size
int bytesRead = await stream.ReadAtLeastAsync(header, headerSize, throwOnEndOfStream: false);
if (bytesRead < headerSize)
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = ["File is too small to be a valid image"]
});
stream.Position = 0;
if (!IsValidImageHeader(header))
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{
["file"] = ["File content does not match an allowed image format"]
});
// 4. Save with a generated filename (never use the original)
var safeName = $"{Guid.NewGuid()}{extension}";
var path = Path.Combine("uploads", safeName);
using var output = File.Create(path);
await stream.CopyToAsync(output);
return TypedResults.Ok(new { FileName = safeName });
})
.DisableAntiforgery(); // Only if using JWT/bearer auth, not cookie auth
static bool IsValidImageHeader(ReadOnlySpan<byte> header) =>
header[..2].SequenceEqual(new byte[] { 0xFF, 0xD8 }) // JPEG
|| header[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) // PNG
|| header[..4].SequenceEqual("GIF8"u8) // GIF
|| (header[..4].SequenceEqual("RIFF"u8) && header[8..12].SequenceEqual("WEBP"u8)); // WebP
For OWASP injection prevention beyond input validation (SQL injection, XSS, command injection), see [skill:dotnet-security-owasp].
IValidator<T> instead.Regex without a timeout or [GeneratedRegex] -- unbounded regex matching on user input enables ReDoS attacks. Always set matchTimeout or use source-generated regex.Content-Type headers -- validate file content by reading magic bytes. Attackers rename executables with image extensions.validateAllProperties: true -- Validator.TryValidateObject without this flag only validates [Required] attributes, silently skipping [Range], [StringLength], and others.[StringLength] or [MaxLength] to every user-controlled string property.AddValidation, [ValidatableType], Microsoft.Extensions.Validation)Microsoft.Extensions.Validation package for .NET 10 built-in validationFluentValidation and FluentValidation.DependencyInjectionExtensions for FluentValidation patterns[GeneratedRegex] source-generated regular expressions