mcpField
D
dotnet-input-validation

dotnet-input-validation

github.com/wshaddix开发8675 次安装35 周前更新
代码仓库

关于 dotnet-input-validation

面向 .NET API 开发者的输入验证技能,介绍如何选择并集成验证方案,涵盖 .NET 10 内置验证、FluentValidation、Data Annotations、Minimal API 过滤器、错误响应及安全验证。

主要功能

  1. 01选择合适的 .NET 验证框架
  2. 02配置 .NET 10 内置验证
  3. 03用 FluentValidation 编写复杂规则
  4. 04使用 Data Annotations 和端点过滤器
  5. 05返回 ProblemDetails 验证错误
  6. 06采用面向安全的输入检查

适用场景

  1. 01为新的 .NET API 选择验证方案
  2. 02添加跨属性或条件业务规则
  3. 03在 Minimal API 处理程序运行前验证请求
  4. 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 模式验证)。


验证框架决策树

根据项目需求选择验证框架:

  1. .NET 10 内置验证(AddValidation)——新建 .NET 10+ 项目的默认选择。基于源生成器,与 AOT 兼容,可从 Minimal API 处理程序自动发现类型。适用于:面向 .NET 10+ 的全新项目。
  2. FluentValidation——验证规则复杂时使用(涉及多个属性、条件或数据库)。提供丰富的流畅 API 和可测试的验证器类。适用于:复杂业务规则、领域验证。
  3. Data Annotations——模型需要简单的声明式验证([Required]、[Range])时使用。广为人知,可与 MVC 模型绑定及用于跨属性检查的 IValidatableObject 配合使用。适用于:简单 DTO、共享模型。
  4. 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] 特性会在编译时触发代码生成——运行时无需反射。

工作原理
  1. 源生成器会发现 [ValidatableType] 类,并生成类似 IValidatableObject 的验证逻辑。
  2. AddValidation() 会注册一个端点筛选器,用于检查 Minimal API 处理程序的参数。
  3. 收到请求时,筛选器会在处理程序执行前验证参数。
  4. 验证失败时,会自动返回 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 注意事项

  1. 不要使用 FluentValidation 自动验证管道——它已弃用,并在 FluentValidation 11 中移除。请改用手动验证,或使用 IValidator<T> 的端点筛选器。
  2. 不要在同一个 DTO 上混用验证框架——每种模型类型选择一种(Data Annotations、FluentValidation 或 .NET 10 内置验证)。混用会导致部分验证行为令人困惑。
  3. 不要使用不带超时的 Regex 或不使用 [GeneratedRegex]——对用户输入进行无限时长的正则匹配会导致 ReDoS 攻击。务必设置 matchTimeout 或使用源生成的正则表达式。
  4. 不要信任客户端提供的 Content-Type 标头——读取文件的魔数来验证内容。攻击者可能会将可执行文件重命名为图像扩展名。
  5. 不要忘记 validateAllProperties: true——调用 Validator.TryValidateObject 时若不设置此标志,只会验证 [Required] 特性,并会悄然跳过 [Range]、[StringLength] 等特性。
  6. 不要使用拒绝列表进行安全验证——拒绝列表本质上并不完整。务必根据已知安全值的允许列表进行验证。
  7. 不要遗漏字符串输入的最大长度——无界字符串可能导致内存耗尽。对每个由用户控制的字符串属性应用 [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] 源生成的正则表达式

参考资料

评论

支持 Markdown