Model Binding and Validation
|
This section documents ASP.NET MVC 5.3.x, ASP.NET Web API 2.2, ASP.NET Web Pages 3, OWIN/Katana, SignalR 2,
and ASP.NET Identity 2 — all running on .NET Framework 4.8.1 — the This is not ASP.NET Core MVC. ASP.NET MVC 5 is the pre-Core, This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page documents ASP.NET MVC 5.3.x model binding on .NET Framework 4.8.1 — not ASP.NET Core’s model binding, which shares the same conceptual model but a different type set (see Model Binding and Validation under ASP.NET Core).
Binding sources and precedence
DefaultModelBinder fills action parameters and complex objects by checking value providers in order — route data, then the query string, then posted form values (and uploaded files via a distinct
HttpPostedFileBase/HttpFileCollectionBase path) — the first provider with a matching key wins:
[HttpPost]
public ActionResult Search(string q, int page = 1)
{
// "q" and "page" are pulled from route data, then querystring, then form -- whichever supplies them first
}
Prefixes and complex/collection binding
A prefix scopes binding to a sub-object, useful when a form posts multiple objects or when field names don’t match property names directly:
@Html.TextBoxFor(m => m.ShippingAddress.Street) <!-- generates name="ShippingAddress.Street" -->
public ActionResult Save([Bind(Prefix = "ShippingAddress")] Address address) { ... }
Collections bind from indexed or unindexed name patterns MVC’s binder understands natively:
<input name="Items[0].Name" /> <input name="Items[0].Qty" />
<input name="Items[1].Name" /> <input name="Items[1].Qty" />
public ActionResult Save(List<LineItem> items) { ... } // binds Items[0], Items[1], ... automatically
[Bind(Include/Exclude)] and the over-posting hazard
Binding a request straight onto an EF entity is convenient and dangerous: a client can post any field name,
including ones no visible form field exposes (e.g. IsAdmin, AccountBalance) — over-posting / mass
assignment. [Bind] restricts which properties the binder is allowed to set:
[HttpPost]
public ActionResult Edit([Bind(Include = "Name,Email,Address")] Customer customer)
{
// customer.IsAdmin can never be set from this action, regardless of what the client posts
}
Exclude is the inverse (deny-list) and is generally the weaker choice — a newly added sensitive property is
bound by default unless the exclude list is remembered and updated. The safer, and now generally preferred,
fix is to bind to a dedicated view model that simply has no IsAdmin property at all, then map the validated
view model onto the entity explicitly:
public class CustomerEditViewModel { public string Name { get; set; } public string Email { get; set; } }
[HttpPost]
public ActionResult Edit(CustomerEditViewModel model)
{
if (!ModelState.IsValid) return View(model);
var customer = _repository.Find(model.Id);
customer.Name = model.Name;
customer.Email = model.Email; // IsAdmin is simply never touched
_repository.Update(customer);
return RedirectToAction("Index");
}
IModelBinder, ModelBinderProvider, IValueProvider
A custom binder handles a type the default binder can’t map cleanly (e.g. a value object parsed from a single string):
public class MoneyModelBinder : IModelBinder
{
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName)?.AttemptedValue;
return decimal.TryParse(value, NumberStyles.Currency, CultureInfo.CurrentCulture, out var amount)
? new Money(amount) : null;
}
}
// Global.asax.cs: ModelBinders.Binders.Add(typeof(Money), new MoneyModelBinder());
IValueProvider (and ValueProviderFactories.Factories) is the abstraction the binder reads from (route
data, form, query string, JSON body); a custom IValueProvider can add a new source (e.g. HTTP headers)
without touching every binder that needs it.
DataAnnotations and IValidatableObject
public class RegisterViewModel : IValidatableObject
{
[Required, StringLength(100, MinimumLength = 2)]
public string Name { get; set; }
[Required, RegularExpression(@"^[^@]+@[^@]+\.[^@]+$")]
public string Email { get; set; }
[Range(18, 120)]
public int Age { get; set; }
[Compare(nameof(Password))]
public string ConfirmPassword { get; set; }
public string Password { get; set; }
[Remote("IsEmailAvailable", "Account")] // AJAX call back to the server during client validation
public string EmailForRemoteCheck { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Age < 21 && Name?.StartsWith("Admin") == true)
yield return new ValidationResult("Admin accounts require an age of 21+.", new[] { nameof(Age) });
}
}
IValidatableObject runs after all DataAnnotations attributes pass, for rules that span multiple properties.
ModelState
[HttpPost]
public ActionResult Register(RegisterViewModel model)
{
if (!ModelState.IsValid) return View(model); // re-render with validation messages intact
if (_users.EmailExists(model.Email))
ModelState.AddModelError(nameof(model.Email), "That email is already registered.");
if (!ModelState.IsValid) return View(model);
// ... create the user ...
return RedirectToAction("Index");
}
ModelState accumulates both binding failures (a non-numeric value posted to an int property) and
DataAnnotations failures automatically; AddModelError adds application-level rules on top.
Unobtrusive client validation
MVC 5 renders data-val-* attributes from the same DataAnnotations, and the jquery.validate
jquery.validate.unobtrusive scripts (bundled by the default template — see
Bundling and Client-Side Integration) read those
attributes to validate in the browser before a form ever posts, without hand-written JavaScript per field:
<input type="email" data-val="true" data-val-regex="..." data-val-required="The Email field is required." ... />
<!-- web.config -->
<appSettings>
<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />
</appSettings>
[AllowHtml] and request validation
ASP.NET’s request validation rejects any posted value that looks like markup (<, <, …) by default, as
a blunt XSS defense. [AllowHtml] opts a specific property out for cases where HTML input is legitimate (a
rich-text editor field) — it must be paired with sanitizing that value before ever rendering it back with
Html.Raw (see Security Hardening):
public class ArticleViewModel
{
[AllowHtml]
public string BodyHtml { get; set; } // sanitize (e.g. with HtmlSanitizer) before ever calling Html.Raw on this
}
Next: Filters covers the cross-cutting pipeline that runs around every action,
including where [ValidateAntiForgeryToken] fits.