关于 dotnet-input-validation
面向 .NET API 开发者的输入验证技能,介绍如何选择并集成验证方案,涵盖 .NET 10 内置验证、FluentValidation、Data Annotations、Minimal API 过滤器、错误响应及安全验证。
主要功能
- 01选择合适的 .NET 验证框架
- 02配置 .NET 10 内置验证
- 03用 FluentValidation 编写复杂规则
- 04使用 Data Annotations 和端点过滤器
- 05返回 ProblemDetails 验证错误
- 06采用面向安全的输入检查
适用场景
- 01为新的 .NET API 选择验证方案
- 02添加跨属性或条件业务规则
- 03在 Minimal API 处理程序运行前验证请求
- 04统一 API 验证错误格式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-input-validation安装后在 Claude Code 中输入/dotnet-input-validation确认已加载。
详细介绍
dotnet-input-validation
面向 .NET API 的全面输入验证模式。涵盖 .NET 10 内置验证系统、用于复杂业务规则的 FluentValidation、用于简单模型的 Data Annotations、用于集成 Minimal API 的端点筛选器、ProblemDetails 错误响应,以及以安全为重点的验证技术。
**范围边界:**本技能负责实用的验证框架指南——何时使用各个框架、如何配置和集成它们,以及以安全为重点的输入处理技巧。OWASP 安全原则(注入类别、威胁建模)——请参阅 [skill:dotnet-security-owasp]。架构验证策略(验证在整洁架构、垂直切片中的位置)——请参阅 [skill:dotnet-architecture-patterns]。使用 ValidateDataAnnotations() 进行 Options 模式验证——请参阅 [skill:dotnet-csharp-configuration]。
**范围之外:**Blazor 表单验证(EditForm、DataAnnotationsValidator)——请参阅 [skill:dotnet-blazor-auth]。OWASP 注入防护原则——请参阅 [skill:dotnet-security-owasp]。验证位置相关的架构模式——请参阅 [skill:dotnet-architecture-patterns]。Options 模式的 ValidateDataAnnotations——请参阅 [skill:dotnet-csharp-configuration]。
交叉引用:[skill:dotnet-security-owasp](OWASP 注入防护)、[skill:dotnet-architecture-patterns](架构验证策略)、[skill:dotnet-minimal-apis](Minimal API 管道集成)、[skill:dotnet-csharp-configuration](Options 模式验证)。
验证框架决策树
根据项目需求选择验证框架:
- .NET 10 内置验证(
AddValidation)——新建 .NET 10+ 项目的默认选择。基于源生成器,与 AOT 兼容,可从 Minimal API 处理程序自动发现类型。适用于:面向 .NET 10+ 的全新项目。 - FluentValidation——验证规则复杂时使用(涉及多个属性、条件或数据库)。提供丰富的流畅 API 和可测试的验证器类。适用于:复杂业务规则、领域验证。
- Data Annotations——模型需要简单的声明式验证(
[Required]、[Range])时使用。广为人知,可与 MVC 模型绑定及用于跨属性检查的IValidatableObject配合使用。适用于:简单 DTO、共享模型。 - MiniValidation——轻量级的 Data Annotations 执行器,无需承担 MVC 模型绑定开销。适用于:需要简单验证的微服务(详情请参阅 [skill:dotnet-architecture-patterns])。
一般建议:新项目优先使用 .NET 10 内置验证。规则超出注解所能表达的范围时,使用 FluentValidation。不要在同一个请求 DTO 中混用多个框架——每种模型类型选用一种框架,并保持一致。
.NET 10 内置验证
.NET 10 引入了 Microsoft.Extensions.Validation,该组件基于源生成器进行验证,并直接集成到 Minimal API 管道中。它会从端点处理程序参数中自动发现可验证类型,并通过端点筛选器运行验证。
设置
// <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() 会扫描标注了 [ValidatableType] 的类型,并使用源生成器在编译时生成验证逻辑,确保与 Native AOT 兼容。
定义可验证类型
[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; }
}
必须使用 partial 关键字,因为源生成器会将验证逻辑生成到同一类型中。[ValidatableType] 特性会在编译时触发代码生成——运行时无需反射。
工作原理
- 源生成器会发现
[ValidatableType]类,并生成类似IValidatableObject的验证逻辑。 AddValidation()会注册一个端点筛选器,用于检查 Minimal API 处理程序的参数。- 收到请求时,筛选器会在处理程序执行前验证参数。
- 验证失败时,会自动返回
ValidationProblem响应。
注意事项:AddValidation() 通过端点筛选器与 Minimal API 集成。MVC 控制器使用自己的模型验证管道,不会参与这一基于筛选器的系统。对于控制器,Data Annotations 和 ModelState.IsValid 仍是标准做法。
FluentValidation
FluentValidation 提供用于构建强类型验证规则的流畅 API。它擅长处理复杂的业务验证,包括跨属性规则、条件逻辑以及依赖数据库的检查。
验证器定义
// <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");
});
}
}
通过程序集扫描注册 DI
// Registers all AbstractValidator<T> implementations from the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);
手动验证模式(推荐)
FluentValidation 的 ASP.NET 管道自动验证已弃用。请改为在端点处理程序或端点筛选器中手动验证:
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);
});
FluentValidation 端点筛选器
若要在多个端点间复用验证逻辑,请创建通用端点筛选器(筛选器管道详情也可参阅 [skill:dotnet-minimal-apis]):
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>>();
**注意事项:**不要使用已弃用的 FluentValidation.AspNetCore 自动验证管道。它已在 FluentValidation 11 中移除。请按上述方式使用手动验证或端点筛选器。
Data Annotations
Data Annotations 通过特性提供声明式验证。它们适用于 MVC 模型绑定、Minimal API 绑定,以及 .NET 10 的 AddValidation() 源生成器。
标准特性
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; }
}
自定义 ValidationAttribute
[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; }
}
使用 IValidatableObject 进行跨属性验证
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) });
}
}
}
**注意事项:**Options 模式类必须使用 { get; set; } 而不是 { get; init; },因为配置绑定器需要在构造后修改属性。仅限 init 的属性上的验证特性适用于请求 DTO,但用于通过 IConfiguration 绑定的 Options 类时会失败。有关 Options 模式验证,请参阅 [skill:dotnet-csharp-configuration]。
用于验证的端点筛选器
端点筛选器会将验证作为横切关注点集成到 Minimal API 请求管道中。筛选器会在处理程序之前执行,因此可以集中处理验证逻辑。
通用 Data Annotations 筛选器
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>>();
与路由组结合使用
在路由组级别应用验证筛选器,确保组内所有端点的验证逻辑一致(路由组模式请参阅 [skill:dotnet-minimal-apis]):
var orders = app.MapGroup("/api/orders")
.AddEndpointFilter<FluentValidationFilter<CreateOrderRequest>>();
**注意事项:**筛选器的执行顺序很重要——最先注册的筛选器位于最外层。请在日志记录筛选器之后、授权增强筛选器之前注册验证筛选器,以便尽早拒绝无效请求,避免不必要的处理。
错误响应
使用 ProblemDetails 标准(RFC 9457)统一 API 错误响应。ASP.NET Core 通过 TypedResults.ValidationProblem() 和 IProblemDetailsService 内置支持。
ValidationProblem 响应
// 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);
});
自定义 ProblemDetails
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();
使用 IProblemDetailsService 进行全局错误处理
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"
}
});
});
});
注意事项:ConfigureHttpJsonOptions 仅适用于 Minimal API,不适用于 MVC 控制器。如果未统一配置 JSON 选项,Minimal API 和 MVC 的验证错误格式(例如 errors 字典中的驼峰式属性名)可能会不同。对于 MVC 控制器,请通过 builder.Services.AddControllers().AddJsonOptions(...) 进行配置。
面向安全的验证
输入验证是防范注入和滥用的第一道防线。这些模式通过实用的验证技术,补充了 [skill:dotnet-security-owasp] 中的 OWASP 安全原则。
ReDoS 防护
带回溯的正则表达式可能被利用,导致性能急剧下降(正则表达式拒绝服务)。务必设置超时或使用源生成的正则表达式。
// 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");
}
允许列表与拒绝列表
始终优先使用允许列表验证,而不是拒绝列表。拒绝列表本质上并不完整,因为新的攻击途径可以绕过它们。
// 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 '-');
强制执行最大长度
对所有由用户控制的字符串输入强制执行最大长度,以防止内存耗尽和基于缓冲区的攻击:
[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;
}
还应在 Kestrel 层强制执行,以实现纵深防御:
// Configure BEFORE builder.Build()
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB
options.Limits.MaxRequestHeadersTotalSize = 32 * 1024; // 32 KB
});
文件上传验证
根据内容类型、大小和扩展名验证文件上传——绝不要仅信任客户端提供的 Content-Type 标头:
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
有关输入验证之外的 OWASP 注入防护(SQL 注入、XSS、命令注入),请参阅 [skill:dotnet-security-owasp]。
Agent 注意事项
- 不要使用 FluentValidation 自动验证管道——它已弃用,并在 FluentValidation 11 中移除。请改用手动验证,或使用
IValidator<T>的端点筛选器。 - 不要在同一个 DTO 上混用验证框架——每种模型类型选择一种(Data Annotations、FluentValidation 或 .NET 10 内置验证)。混用会导致部分验证行为令人困惑。
- 不要使用不带超时的
Regex或不使用[GeneratedRegex]——对用户输入进行无限时长的正则匹配会导致 ReDoS 攻击。务必设置matchTimeout或使用源生成的正则表达式。 - 不要信任客户端提供的
Content-Type标头——读取文件的魔数来验证内容。攻击者可能会将可执行文件重命名为图像扩展名。 - 不要忘记
validateAllProperties: true——调用Validator.TryValidateObject时若不设置此标志,只会验证[Required]特性,并会悄然跳过[Range]、[StringLength]等特性。 - 不要使用拒绝列表进行安全验证——拒绝列表本质上并不完整。务必根据已知安全值的允许列表进行验证。
- 不要遗漏字符串输入的最大长度——无界字符串可能导致内存耗尽。对每个由用户控制的字符串属性应用
[StringLength]或[MaxLength]。
先决条件
- .NET 8.0+(端点筛选器、ProblemDetails、Data Annotations 的 LTS 基准版本)
- .NET 10.0,用于内置验证(
AddValidation、[ValidatableType]、Microsoft.Extensions.Validation) - .NET 10 内置验证所需的
Microsoft.Extensions.Validation包 - FluentValidation 模式所需的
FluentValidation和FluentValidation.DependencyInjectionExtensions - .NET 7+,用于
[GeneratedRegex]源生成的正则表达式