dotnet-input-validation について
.NET API 開発者向けに、入力検証方式の選択と統合を解説するスキルです。.NET 10 の組み込み検証、FluentValidation、Data Annotations、Minimal API のフィルター、エラー応答、安全性を意識した検証を扱います。
主な機能
- 01.NET 検証フレームワークの選択
- 02.NET 10 組み込み検証の設定
- 03FluentValidation による複雑なルール
- 04Data Annotations とエンドポイントフィルター
- 05ProblemDetails による検証エラー応答
- 06安全性を意識した入力チェック
活用シーン
- 01新しい .NET API の検証方式選定
- 02属性間・条件付き業務ルールの追加
- 03Minimal API のハンドラー実行前の検証
- 04API 検証エラー形式の統一
インストール
コマンドをコピーしてターミナルで実行します。
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 — 検証ルールが複雑な場合(プロパティ間の検証、条件付き検証、データベース依存の検証)に使用します。テスト可能なバリデータークラスを作成できる、豊富な Fluent API を備えています。適している用途:複雑なビジネスルール、ドメイン検証。
- Data Annotations — モデルにシンプルな宣言型検証(
[Required]、[Range])が必要な場合に使用します。広く知られており、MVC モデルバインディングや、プロパティ間の検証に使うIValidatableObjectに対応しています。適している用途:シンプルな DTO、共有モデル。 - MiniValidation — MVC モデルバインディングのオーバーヘッドを伴わない、軽量な Data Annotations ランナーです。適している用途:シンプルな検証を使うマイクロサービス(詳細については[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; init; } ではなく { get; set; } を使用する必要があります。これは、構成バインダーがインスタンスの構築後にプロパティを変更する必要があるためです。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>>();
注意点: フィルターの実行順序は重要です。最初に登録したフィルターが最も外側になります。無効なリクエストを不要な処理なしで早期に拒否できるよう、検証フィルターはログ記録の後、認可情報の付加処理の前に登録してください。
エラーレスポンス
一貫性のある API エラーレスポンスには、ProblemDetails 標準(RFC 9457)を使用します。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 オプションが一貫して設定されていない場合、検証エラーの形式(たとえば、errors ディクショナリ内のプロパティ名の camelCase 表記)が Minimal API と MVC で異なることがあります。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] を参照してください。
エージェントが注意すべき点
- FluentValidation の自動検証パイプラインを使用しないでください — 非推奨となり、FluentValidation 11 で削除されました。代わりに、手動で検証するか、
IValidator<T>を使用するエンドポイントフィルターを使ってください。 - 同じ DTO で複数の検証フレームワークを混在させないでください — モデルの種類ごとに 1 つ(Data Annotations、FluentValidation、または .NET 10 組み込み機能)を選んでください。混在させると、一部だけが検証され、挙動が分かりにくくなります。
- タイムアウトも
[GeneratedRegex]も設定せずにRegexを使用しないでください — ユーザー入力に対する無制限の正規表現マッチングは、ReDoS 攻撃を可能にします。必ずmatchTimeoutを設定するか、ソース生成正規表現を使用してください。 - クライアントが指定した
Content-Typeヘッダーを信頼しないでください — マジックバイトを読み取り、ファイルの内容を検証してください。攻撃者は実行ファイルの拡張子を画像のものに変更できます。 validateAllProperties: trueを忘れないでください — このフラグを指定せずにValidator.TryValidateObjectを呼び出すと、[Required]属性のみが検証され、[Range]、[StringLength]などは通知されないままスキップされます。- セキュリティのために拒否リストによる検証を使用しないでください — 拒否リストは本質的に不完全です。必ず既知の安全な値の許可リストに照らして検証してください。
- 文字列入力に最大長を設定し忘れないでください — 長さに制限のない文字列はメモリ枯渇を引き起こします。ユーザーが制御するすべての文字列プロパティに
[StringLength]または[MaxLength]を適用してください。
前提条件
- .NET 8.0 以降(エンドポイントフィルター、ProblemDetails、Data Annotations の LTS ベースライン)
- 組み込みの検証機能(
AddValidation、[ValidatableType]、Microsoft.Extensions.Validation)には .NET 10.0 - .NET 10 の組み込み検証機能には
Microsoft.Extensions.Validationパッケージ - FluentValidation のパターンには
FluentValidationとFluentValidation.DependencyInjectionExtensions - ソース生成正規表現には .NET 7 以降(
[GeneratedRegex])