Model Binding Gotchas
On this page
Model Binding Is an Input Attack Surface
Model binding decides what your code actually receives. If you assume “the framework will parse it”, you will ship endpoints that: - accept invalid input silently - treat missing values as defaults - parse numbers/dates differently per environment - create memory pressure with large payloads - behave inconsistently across controllers vs minimal APIs Production rule: Treat model binding like parsing untrusted input. Because it is.Real Production Incident
Symptoms: - Orders randomly have Quantity=0 even though clients send Quantity. - Only happens for some clients / regions. - No exceptions in logs. - Downstream systems show corrupted data. Root cause: - Client sent Quantity using a different JSON shape or content type. - Binding failed and the property defaulted to 0. - No validation and no model state enforcement. - The service persisted a default value as if it was legitimate. This is silent data corruption caused by binding assumptions.Symptom → Cause → Diagnosis → Fix
Symptom: - “Default” values appear in persisted data (0, false, null) - Inconsistent parsing of dates/numbers - Unexpected 415/400 behavior - Large requests causing high memory Cause: - Binding source ambiguity (route vs query vs body) - Missing [ApiController] behavior or missing validation checks - Culture-specific parsing in query/path - Accepting complex objects from query/body without limits - Wrong content type (text/plain vs application/json) Diagnosis: - Inspect request Content-Type and body. - Log validation failures (without logging raw PII payloads). - Compare endpoint signatures and binding sources. - Add integration tests with real HTTP requests (not unit tests). Fix: - Be explicit about binding sources where ambiguity exists. - Enforce validation and reject invalid/missing required fields. - Set request size limits. - Use consistent formats for dates and numbers. - Prefer JSON body for complex payloads; keep query simple.Binding Sources: Be Explicit When It Matters
Common sources: - Route values: /users/{id} - Query string: ?page=2 - Headers: X-* - Body: JSON payload Ambiguity is where bugs hide. Controller example:
[HttpGet("/v1/users/{id}")]
public IActionResult Get([FromRoute] Guid id) => Ok();
Minimal API example:
app.MapGet("/v1/users/{id:guid}", ([FromRoute] Guid id) => Results.Ok());
Production rule:
If a parameter could come from multiple places, declare the source.
Silent Defaults: The Classic Corruption Bug
Value types default silently: - int -> 0 - bool -> false - Guid -> 00000000-0000-0000-0000-000000000000 If binding fails and you do not validate, you persist nonsense. Anti-pattern:
public sealed class CreateOrderDto
{
public int Quantity { get; set; }
}
If Quantity is missing or invalid, it becomes 0.
Correct approach:
- make required fields explicit
- validate presence and range
- consider nullable types for “must be provided” fields
public sealed class CreateOrderDto
{
public int? Quantity { get; set; }
}
Then validate Quantity != null and > 0.