Skip to content

CWE-1174: ASP.NET Misconfiguration: Improper Model Validation

Overview

ASP.NET's model binder populates an object from the request and, where data annotations are present, records the results of validating it. Recording is all it does. Unless something checks ModelState, or the framework is configured to check it for you, the action method runs with whatever was submitted - the attributes on the model are documentation at that point, not enforcement.

Two things make this weakness persistent. The rules live on the model while the decision to honour them lives in the controller, so the two drift apart as code is added; and different parts of ASP.NET make that decision differently - [ApiController] returns 400 automatically, an MVC controller does not, and a minimal API does not run them unless validation is registered explicitly.

Relationship to Other CWEs

  • CWE-1174 (this page) - whether the values the model binder produced are validated.
  • CWE-915 (Mass Assignment) - which properties may be set at all. Both are decided by the same model-binding step, so they fail together: a ViewModel that excludes IsAdmin addresses CWE-915, and [EmailAddress] on the properties it does expose addresses this one. Fixing either alone leaves a hole.
  • CWE-20 - general input-validation findings that are not ASP.NET-specific.

OWASP Classification

A02:2025 - Security Misconfiguration

Risk

High: Unvalidated model data reaches business logic and storage, breaking invariants the downstream code assumes hold - negative quantities and prices, or strings long enough to exhaust storage or memory. Where binding also reaches entity properties the user should not control, the same request escalates privileges or alters balances directly.

Common Vulnerable Patterns

No ModelState check in an MVC controller

// VULNERABLE - validation ran, and nothing consulted the result
[HttpPost]
public IActionResult UpdateProfile([FromBody] UserProfileModel model)
{
    _userService.UpdateProfile(model);
    return Ok();
}

// POST { "Email": "not-an-email", "Age": -5, "Bio": "<script>alert(1)</script>" }
// binds successfully and is processed

Why this is vulnerable: Data annotations record failures in ModelState during binding - per key, reachable as ModelState[key].Errors - and do not short-circuit the action. On a plain Controller - the MVC case, without [ApiController] - nothing checks ModelState.IsValid for you, so every attribute on UserProfileModel is inert.

Binding a request directly to a database entity

// VULNERABLE - the binder fills every matching property, including ones no form shows
[HttpPost]
public IActionResult UpdateUser([FromBody] User user)   // User is an EF Core entity
{
    if (ModelState.IsValid)
    {
        _db.Users.Update(user);
        _db.SaveChanges();
        return Ok();
    }
    return BadRequest();
}

// POST { "Id": 123, "Name": "Alice", "IsAdmin": true, "Balance": 999999 }

Why this is vulnerable: The ModelState.IsValid check is present and does not help - IsAdmin: true is perfectly valid data. Binding to the entity means any property the JSON names is set, and the UI never showing a field is not a control. _db.Users.Update then writes the whole entity, including the properties the attacker supplied.

Attributes that omit the constraint that matters

// VULNERABLE - present but insufficient
public class ProductModel
{
    [Required]
    public string Name { get; set; }        // no maximum length

    public decimal Price { get; set; }      // no range - negatives accepted

    [EmailAddress]
    public string? Email { get; set; }      // not [Required]: null passes
}

Why this is vulnerable: Each of these reads as validated. [Required] without [StringLength] accepts a megabyte string; a decimal with no [Range] accepts -999999, which becomes a credit if the price is ever multiplied by a quantity; and a format attribute alone passes on null, because format validators skip missing values by design - that is what [Required] is for. Email is declared string? deliberately: with nullable reference types on, MVC treats a non-nullable string property as implicitly required, so writing it as public string Email would have produced the model-state error the example is trying to show the absence of.

That implicit rule is narrower than it sounds, and the gap is worth knowing because it decides whether an explicit [Required] is redundant. Measured on .NET 10: the implicit rule rejects a null, and nothing else. A non-nullable string sent "" passes, where an explicit [Required] rejects it, because AllowEmptyStrings defaults to false. And an initialiser removes the rule altogether - public string Email { get; set; } = string.Empty; is never null, so no error is ever recorded, and that is the spelling the compiler pushes you toward to silence CS8618. The upshot is that on a property written the ordinary way, non-nullability buys you nothing: the [Required] attributes on the ViewModel below are load-bearing, not decoration.

Minimal APIs, where annotations are not evaluated at all

// VULNERABLE - no validation runs, and no ModelState exists to check
app.MapPost("/api/products", (ProductModel product, IProductService svc) =>
{
    svc.Create(product);       // attributes on ProductModel are never evaluated
    return Results.Ok();
});

Why this is vulnerable: Minimal APIs bind and deserialize, but nothing runs data-annotation validation for them by default, and there is no ModelState to inspect. Code moved from a controller to a minimal API therefore loses its validation silently - the model class is unchanged, the attributes are still there, and nothing executes them. From .NET 10 the gap is opt-in rather than unavoidable: builder.Services.AddValidation() wires up annotation validation for minimal APIs, and an endpoint is only vulnerable if that registration is absent. That is still true in .NET 11 - checked against the RC, where validation remains something you call AddValidation() to enable. What .NET 11 adds is asynchronous validation (AsyncValidationAttribute and IAsyncValidatableObject, which Microsoft.Extensions.Validation runs when an endpoint validates) and the removal of the experimental marker from ValidatableTypeAttribute and SkipValidationAttribute, so a project suppressing ASP0029 to use either can drop the suppression. Before .NET 10 there is no built-in option at all, which is what the filter below supplies.

One registration trap is worth knowing, because it produces exactly this weakness in a codebase that looks correct. The source generator behind AddValidation() only discovers validatable types in the assembly where AddValidation() is called. If the endpoints live in a referenced assembly and the call is made in the host, Microsoft's documentation is explicit that "validation doesn't execute: Invalid requests are processed and return a 200 - OK response instead of the expected 400 - Bad Request response, even though AddValidation is registered and the request types use validation attributes". The fix is an extension method in each assembly that defines endpoints, which itself calls AddValidation(), invoked from the host. A finding here will not look like a missing registration, because the registration is present.

Secure Patterns

Check ModelState, and constrain every property

// SECURE - the result of validation is acted on
[HttpPost]
public IActionResult UpdateProfile([FromBody] UserProfileModel model)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    _userService.UpdateProfile(model);
    return Ok();
}

public class UserProfileModel
{
    [Required]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    [StringLength(254)]
    public string Email { get; set; } = string.Empty;

    [Range(13, 120)]
    public int Age { get; set; }

    [StringLength(500)]
    public string? Bio { get; set; }
}

Why this works: ModelState.IsValid is the step that turns recorded errors into a rejected request, and ValidationProblem(ModelState) returns them as RFC 7807 problem details so the client can show which field failed. BadRequest(ModelState) also produces a 400, but serialises a flat field-to-messages map rather than problem details - fine if that is what the client expects, and a silent difference if it is not. On the model, each property carries both a presence rule and a bound: [Required] for existence, [StringLength]/[Range] for size, and a format attribute where the shape matters. The nullable annotation on Bio states that it is genuinely optional, rather than leaving the reader to infer it from the absence of [Required].

A ViewModel scoped to the operation

// SECURE - the request cannot name a property that is not on this type
public class UpdateUserViewModel
{
    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    [StringLength(254)]
    public string Email { get; set; } = string.Empty;
}

[HttpPost]
public IActionResult UpdateUser(int id, [FromBody] UpdateUserViewModel input)
{
    if (!ModelState.IsValid)
    {
        return ValidationProblem(ModelState);
    }

    var user = _db.Users.Find(id);
    if (user is null)
    {
        return NotFound();
    }

    user.Name = input.Name;      // only these two properties can change
    user.Email = input.Email;

    _db.SaveChanges();
    return Ok();
}

Why this works: IsAdmin cannot be over-posted because there is nowhere for it to land - the binder has no such property to set. This is stronger than filtering the request, because it holds no matter what the client sends and survives someone later adding a sensitive column to the entity. The explicit assignment then makes the set of mutable fields reviewable in one place, and the entity is loaded by id from the route rather than trusted from the body.

Cross-field rules with IValidatableObject

// SECURE - rules that no single attribute can express
public class OrderModel : IValidatableObject
{
    [Range(1, 10_000)]
    public int Quantity { get; set; }

    [Range(0.01, 999_999.99)]
    public decimal UnitPrice { get; set; }

    [Range(0, 100)]
    public decimal DiscountPercent { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (DiscountPercent > 0 && Quantity < 10)
        {
            yield return new ValidationResult(
                "Discounts require an order of 10 or more",
                new[] { nameof(DiscountPercent), nameof(Quantity) });
        }

        if (Quantity > 1_000)
        {
            yield return new ValidationResult(
                "Orders over 1000 units require manual approval",
                new[] { nameof(Quantity) });
        }
    }
}

Why this works: Validate runs as part of model binding and its results land in the same ModelState, so a single IsValid check covers both attribute rules and business rules. Naming the member in each ValidationResult keeps the error attached to a field rather than surfacing as a form-level message. The property-level attributes run first and Validate is only reached if they pass, so it can assume the ranges hold.

Validate explicitly in minimal APIs

// SECURE - an endpoint filter restores what MVC would have done
public sealed class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var model = context.Arguments.OfType<T>().FirstOrDefault();
        if (model is null)
        {
            return Results.BadRequest("Missing request body");
        }

        var results = new List<ValidationResult>();
        if (!Validator.TryValidateObject(model, new ValidationContext(model), results, true))
        {
            // One member can fail several validators and one result can name
            // several members, so group instead of building a 1:1 dictionary -
            // ToDictionary throws on the duplicate key and returns a 500.
            return Results.ValidationProblem(results
                .SelectMany(
                    r => r.MemberNames.DefaultIfEmpty(string.Empty),
                    (r, member) => (member, message: r.ErrorMessage ?? "Invalid"))
                .GroupBy(x => x.member)
                .ToDictionary(g => g.Key, g => g.Select(x => x.message).ToArray()));
        }

        return await next(context);
    }
}

// The filter makes the annotations run; it cannot supply ones the model lacks.
// This is the ProductModel above with the constraints it was missing.
public class ProductModel
{
    [Required]
    [StringLength(200)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 1_000_000)]
    public decimal Price { get; set; }

    [Required]
    [EmailAddress]
    [StringLength(254)]
    public string Email { get; set; } = string.Empty;
}

app.MapPost("/api/products", (ProductModel product, IProductService svc) =>
{
    svc.Create(product);
    return Results.Ok();
})
.AddEndpointFilter<ValidationFilter<ProductModel>>();

Why this works: validateAllProperties: true makes Validator.TryValidateObject evaluate every property attribute rather than only [Required], so the annotations on the model finally run, and it invokes IValidatableObject.Validate when the type implements it. The filter sits in front of the handler, so the handler cannot execute on an invalid model, and it is written once per endpoint rather than repeated in every body.

It is not MVC's validator, and the difference matters. MVC validates through ObjectModelValidator, which walks the object graph; TryValidateObject evaluates the attributes on the object handed to it and does not descend into complex properties, so a nested model's own [Range] and [StringLength] never run. The model above is flat, which is why the filter covers it. Where a body has nested objects, validate them explicitly - implement IValidatableObject on the parent and validate the children there, or the filter will report a nested -5 as valid.

Considerations

  • [ApiController] changes the answer, and only for that case. A controller annotated with it returns a 400 automatically when ModelState is invalid, so a missing IsValid check there is not exploitable - unless SuppressModelStateInvalidFilter is set in configuration, which reinstates the weakness silently. MVC controllers returning views, Razor Pages handlers and minimal APIs get no such treatment. Establish which of these the finding is in before deciding it is real.
  • [Bind] does not apply to JSON request bodies. It constrains model binding from form and query data; a [FromBody] payload is handled by an input formatter (System.Text.Json), which ignores it entirely. An API that "fixed" over-posting with [Bind("Username,Email")] on a JSON endpoint is unprotected. Use a dedicated ViewModel - it works for both paths.
  • The required keyword and [Required] are different mechanisms, and both can reject a request. C# 11's required is a compile-time guarantee for code constructing the object, and System.Text.Json enforces it during deserialization too: a JSON body that omits the property fails before any validation runs. What it does not do is reject a value - {"Name": null} satisfies required and is caught only by [Required]. So required governs whether the property is present in the payload and [Required] whether the value is there; a model that needs both guarantees needs both keywords.
  • Validation is not encoding, and treating it as such creates the next bug. Rejecting <script> in a bio field is not what makes the page safe; Razor's contextual encoding is (CWE-79). A regex that filters characters usually breaks legitimate input - names with apostrophes, addresses with ampersands - while missing an encoding the attacker chooses. Validate for shape and size; encode at output.
  • Client-side validation is a UX feature. Unobtrusive validation improves the form and is bypassed by any HTTP client. It never affects whether the server-side finding is real.

Testing

The attributes are visible to a scanner; whether anything enforces them is not. These assertions go through the HTTP layer, because that is where the enforcement lives.

  • POST a model violating each attribute - a negative Price, an Age of 5, a Name of 10,000 characters - and assert HTTP 400 with the offending field named in the response. Assert on the field, not just the status: a 400 produced by a JSON parse error looks identical otherwise.
  • POST a body containing a property the ViewModel does not declare ("IsAdmin": true) and assert both a success response and that the stored entity is unchanged for that column. The request succeeding is the expected behaviour; the database not changing is the assertion that matters.
  • POST with the property omitted entirely and separately with an explicit null. Format attributes pass on missing values, so this is where a field that should be [Required] and is not shows up.
  • Exercise the cross-field rules at their boundaries: quantity 9 with a discount, quantity 10 with a discount, quantity exactly 1000 and 1001.
  • For minimal API endpoints, assert an invalid model is rejected. The model class looks identical to the MVC one, so if validation was assumed rather than wired up, this is the test that fails.
  • Assert valid submissions still succeed for every model touched. Adding [StringLength] and [RegularExpression] to properties that previously had none is the most common way this fix breaks production traffic.

Additional Resources