Skip to content

Validation: non-success ValidationResult with null ErrorMessage is silently dropped (fail-open) #69597

Description

@ViveliDuCh

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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-minimalIncludes minimal APIs, endpoint filters, parameter binding, request delegate generator etcbugThis issue describes a behavior which is not expected - a bug.feature-validationIssues related to model validation in minimal and controller-based APIs

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions