Description
Microsoft.Extensions.Validation determines whether a ValidationAttribute (or IValidatableObject) failed by comparing the returned ValidationResult against ValidationResult.Success (result != ValidationResult.Success), which correctly matches System.ComponentModel.DataAnnotations semantics: any non-null ValidationResult is a failure, regardless of its ErrorMessage.
However, once a failure is detected, ReportError silently discards it if the resolved error message is null:
// src/Validation/gen/Templates/ValidatablePropertyInfo.cs (ReportError)
var errorMessage = ResolveAttributeErrorMessage(context, memberName: Name, displayName, declaringType: DeclaringType, attribute, result);
if (errorMessage is not null)
{
var errorContext = new ValidationError() { Name = Name, Path = context.CurrentValidationPath, ErrorMessage = errorMessage, Container = container };
context.AddValidationError(errorContext);
}
// else: nothing happens - the failure is dropped entirely
The same pattern exists in ValidatableParameterInfo.cs. Because context.ValidationErrors is the sole signal callers use to decide whether validation passed, a legitimate DataAnnotations failure whose ValidationResult.ErrorMessage is null (e.g. a custom ValidationAttribute or IValidatableObject.Validate returning new ValidationResult(null)) is treated as if validation succeeded ("fail open"), even though ValidationAttribute.GetValidationResult returned a non-Success result.
This appears to stem from ValidationError.ErrorMessage being declared required string (non-nullable) — the null-guard was likely added to satisfy that contract, but the side effect is silently dropping real failures instead of substituting a fallback message.
Repro
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Validation;
var services = new ServiceCollection();
services.AddValidation();
var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<ValidationOptions>();
var model = new Model { Name = "anything" };
var context = new ValidateContext { ValidationOptions = options, ServiceProvider = provider };
options.TryGetValidatableTypeInfo(typeof(Model), out var validatableType);
validatableType!.Validate(model, context);
// Expected: context.ValidationErrors is non-null/non-empty because AlwaysFailsAttribute failed.
// Actual: context.ValidationErrors is null - the failure is silently dropped.
Console.WriteLine(context.ValidationErrors is null ? "No errors (BUG: should have failed)" : "Has errors (correct)");
class Model
{
[AlwaysFails]
public string? Name { get; set; }
}
class AlwaysFailsAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
=> new ValidationResult(errorMessage: null); // non-success, but null message
}
Expected behavior
A non-Success ValidationResult should always be surfaced as a validation error, matching System.ComponentModel.DataAnnotations semantics where validity is determined solely by whether the result is null (ValidationResult.Success). If ErrorMessage is null, a fallback message should be substituted (e.g. a generic "The field {0} is invalid." message) rather than discarding the failure.
Actual behavior
Validation reports success (empty/null ValidationErrors) even though a ValidationAttribute returned a non-success ValidationResult, because ReportError requires errorMessage != null before adding the error to ValidateContext.ValidationErrors.
Where
src/Validation/gen/Templates/ValidatablePropertyInfo.cs (ReportError)
src/Validation/gen/Templates/ValidatableParameterInfo.cs (ReportError)
src/Validation/gen/Templates/ValidatableInfo.cs (ResolveAttributeErrorMessage, ValidateSynchronousOnly, ValidateAllAttributesSynchronously)
Description
Microsoft.Extensions.Validationdetermines whether aValidationAttribute(orIValidatableObject) failed by comparing the returnedValidationResultagainstValidationResult.Success(result != ValidationResult.Success), which correctly matchesSystem.ComponentModel.DataAnnotationssemantics: any non-nullValidationResultis a failure, regardless of itsErrorMessage.However, once a failure is detected,
ReportErrorsilently discards it if the resolved error message isnull:The same pattern exists in
ValidatableParameterInfo.cs. Becausecontext.ValidationErrorsis the sole signal callers use to decide whether validation passed, a legitimate DataAnnotations failure whoseValidationResult.ErrorMessageisnull(e.g. a customValidationAttributeorIValidatableObject.Validatereturningnew ValidationResult(null)) is treated as if validation succeeded ("fail open"), even thoughValidationAttribute.GetValidationResultreturned a non-Successresult.This appears to stem from
ValidationError.ErrorMessagebeing declaredrequired string(non-nullable) — the null-guard was likely added to satisfy that contract, but the side effect is silently dropping real failures instead of substituting a fallback message.Repro
Expected behavior
A non-
SuccessValidationResultshould always be surfaced as a validation error, matchingSystem.ComponentModel.DataAnnotationssemantics where validity is determined solely by whether the result isnull(ValidationResult.Success). IfErrorMessageisnull, a fallback message should be substituted (e.g. a generic "The field {0} is invalid." message) rather than discarding the failure.Actual behavior
Validation reports success (empty/null
ValidationErrors) even though aValidationAttributereturned a non-successValidationResult, becauseReportErrorrequireserrorMessage != nullbefore adding the error toValidateContext.ValidationErrors.Where
src/Validation/gen/Templates/ValidatablePropertyInfo.cs(ReportError)src/Validation/gen/Templates/ValidatableParameterInfo.cs(ReportError)src/Validation/gen/Templates/ValidatableInfo.cs(ResolveAttributeErrorMessage,ValidateSynchronousOnly,ValidateAllAttributesSynchronously)