Skip to content

Return distinct error for JSON null body instead of 'non-empty request body'#65595

Open
kubaflo wants to merge 2 commits intodotnet:mainfrom
kubaflo:fix/json-null-body-binding-40415
Open

Return distinct error for JSON null body instead of 'non-empty request body'#65595
kubaflo wants to merge 2 commits intodotnet:mainfrom
kubaflo:fix/json-null-body-binding-40415

Conversation

@kubaflo
Copy link

@kubaflo kubaflo commented Mar 1, 2026

🤖 AI Summary

🔍 Automated Fix Report
🔍 Pre-Flight — Context & Validation

Issue: #40415 - JSON "null" request body rejected as an empty request body
Area: area-mvc (src/Mvc/)
PR: #41002 (closed/stale — will create new)

Key Findings

  • JSON null body (4 bytes: n, u, l, l) deserializes to model == null in both SystemTextJsonInputFormatter and NewtonsoftJsonInputFormatter
  • Both formatters return InputFormatterResult.NoValue() when model is null and TreatEmptyInputAsDefaultValue is false
  • BodyModelBinder then adds error: "A non-empty request body is required." — misleading because the body IS non-empty
  • For nullable parameters: already fixed in .NET 7 via EmptyBodyBehavior inference (Inferring FromBody.AllowEmptyBehavior = Allow based nullability information #39754) — TreatEmptyInputAsDefaultValue = true, so JSON null accepted
  • For non-nullable parameters: error message is wrong — says "non-empty" when body was valid JSON null
  • Team member @brunolins16 suggested: check for JSON-encoded null in formatters, return Failure() with distinct error message

Test Command

dotnet test src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj --filter "FullyQualifiedName~JsonInputFormatter"

Fix Candidates

# Source Approach Files Changed Notes
1 @brunolins16 review Check for JSON null in formatters, return Failure() with distinct message SystemTextJsonInputFormatter.cs, NewtonsoftJsonInputFormatter.cs Team-recommended approach
2 PR #41002 Stream re-read to check for literal "null" Same + stream seeking Overly complex

🧪 Test — Bug Reproduction

Test Result: ✅ TESTS CREATED

Test Command: dotnet test src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj --filter "NullJsonInput"
Tests Created: 2 tests in base class (run by both STJ and Newtonsoft), 2 Newtonsoft-specific

Tests Written

  • ReadAsync_WithNullJsonInput_WhenAllowingEmptyInput_SetsModelToNull — Verifies JSON null is accepted when TreatEmptyInputAsDefaultValue=true
  • ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError — Verifies JSON null returns Failure with distinct error message (not "non-empty request body")
  • ReadAsync_WithWhitespaceInput_WhenAllowingEmptyInput_SetsModelToNull — (Newtonsoft-specific) Whitespace accepted when allowed
  • ReadAsync_WithWhitespaceInput_WhenNotAllowingEmptyInput_ReturnsFailure — (Newtonsoft-specific) Whitespace returns Failure

Conclusion

Tests verify that JSON null body now returns a distinct error message about null values rather than the misleading "non-empty request body required" message. Both SystemTextJsonInputFormatter and NewtonsoftJsonInputFormatter are covered via the shared test base class.


🚦 Gate — Test Verification & Regression

Gate Result: ✅ PASSED

Test Command: dotnet test src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj --filter "JsonInputFormatter"

New Tests vs Buggy Code

  • ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError: FAIL as expected (expected HasError=true, got HasError=false with buggy code)

Regression Check

  • STJ formatter tests: 51 passed, 1 skipped, 0 failed
  • Newtonsoft formatter tests: 72 passed, 1 skipped, 0 failed
  • No new regressions introduced

Conclusion

Tests properly verify the bug fix. The key gate test fails on buggy code because the old code returns NoValue() (HasError=false) instead of Failure() (HasError=true) with a distinct error message.


🔧 Fix — Analysis & Comparison (✅ 5 passed)

Fix Exploration Summary

Total Attempts: 5
Passing Candidates: 5
Selected Fix: Attempt 0 — Return Failure() with distinct resource string in formatters

Attempt Results

# Model Approach Result Key Insight
0 claude-sonnet-4.6 Return Failure() with new resource string in formatters, hadJsonContent flag in Newtonsoft ✅ Pass Most surgical — changes at exact point of null detection
1 claude-opus-4.6 Change BodyModelBinder to check ContentLength for different error message ✅ Pass Formatter-agnostic — works for any input formatter
2 gpt-5.2 Detect JSON null at formatter level via stream position/content check ✅ Pass Similar to attempt 0 with different detection mechanism
3 gpt-5.3-codex Use existing ValueMustNotBeNullAccessor for error message ✅ Pass Reuses existing message infrastructure — minimal new code
4 gemini-3-pro-preview Override ReadAsync (not ReadRequestBodyAsync) as wrapper ✅ Pass Clean separation but adds public API surface

Cross-Pollination

All models converge on the same core insight: InputFormatterResult.NoValue() is wrong when the body contains JSON null because BodyModelBinder interprets it as "empty body". Models differ on WHERE to make the change (formatter vs binder) and HOW to detect JSON null.

Exhausted: Yes

Comparison

Criterion Attempt 0 (formatter Failure) Attempt 1 (binder ContentLength) Attempt 3 (ValueMustNotBeNull) Attempt 4 (ReadAsync override)
Correctness ✅ (heuristic — ContentLength may not be set)
Simplicity Good (2 formatter changes) ⭐ Best (1 binder change) Good Moderate (public API change)
Robustness ⭐ Best (hadJsonContent for Newtonsoft) Moderate (relies on ContentLength) Good Good
Team guidance ✅ Matches @brunolins16 suggestion Different approach Reuses existing messages Different approach
Backward compat ✅ Internal only ✅ Internal only ✅ Internal only ⚠️ New public API

Recommendation

Attempt 0 is the best fix:

  • Follows the team member (@brunolins16) suggestion to handle it in the formatters
  • Uses a precise hadJsonContent flag in Newtonsoft to avoid breaking the exhausted-stream edge case
  • No public API changes — all modifications are internal
  • Clear, specific error message that includes the parameter name
  • Handles both SystemTextJson and Newtonsoft formatters consistently
Attempt 0: PASS

Attempt 0: Return Failure with distinct error message in formatters

Model: claude-sonnet-4.6 (manual implementation)

Approach

  • Add new resource string FormatExceptionMessage_NullJsonIsInvalid to both Mvc.Core and Mvc.NewtonsoftJson
  • In SystemTextJsonInputFormatter: when model == null && !TreatEmptyInputAsDefaultValue, add model error and return Failure() instead of NoValue()
  • In NewtonsoftJsonInputFormatter: same change, but with hadJsonContent flag check (tracking jsonReader.TokenType != JsonToken.None) to distinguish JSON null from exhausted stream

Key Design Decisions

  • STJ: No need for hadJsonContent check because STJ throws JsonException on empty/exhausted streams
  • Newtonsoft: Must check hadJsonContent because Newtonsoft returns null (no exception) for empty streams
  • Error message includes the parameter name for better diagnostics

Files Changed

  • src/Mvc/Mvc.Core/src/Resources.resx
  • src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
  • src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
  • src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
  • src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
  • src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs
📄 Diff
diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
index 81c6571140..d5226acdcc 100644
--- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
@@ -3,6 +3,7 @@
 
 using System.Text;
 using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.Core;
 using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Mvc.Formatters;
@@ -117,11 +118,16 @@ public partial class SystemTextJsonInputFormatter : TextInputFormatter, IInputFo
 
         if (model == null && !context.TreatEmptyInputAsDefaultValue)
         {
-            // Some nonempty inputs might deserialize as null, for example whitespace,
-            // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-            // be notified that we don't regard this as a real input so it can register
-            // a model binding error.
-            return InputFormatterResult.NoValue();
+            // The request body contained a valid JSON value (e.g. the literal "null")
+            // that deserialized to null, but the parameter is required. Return a
+            // specific error message rather than the generic "non-empty request body"
+            // message, because the body is not empty — it just contains null.
+            var modelName = context.ModelName;
+            context.ModelState.TryAddModelError(
+                modelName,
+                Resources.FormatFormatExceptionMessage_NullJsonIsInvalid(modelName));
+
+            return InputFormatterResult.Failure();
         }
         else
         {
diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx
index 6908f9047b..b938be66fa 100644
--- a/src/Mvc/Mvc.Core/src/Resources.resx
+++ b/src/Mvc/Mvc.Core/src/Resources.resx
@@ -300,6 +300,9 @@
   <data name="ModelBinding_MissingRequestBodyRequiredMember" xml:space="preserve">
     <value>A non-empty request body is required.</value>
   </data>
+  <data name="FormatExceptionMessage_NullJsonIsInvalid" xml:space="preserve">
+    <value>A JSON request body containing a null value is not valid for the required '{0}' parameter.</value>
+  </data>
   <data name="ValueProviderResult_NoConverterExists" xml:space="preserve">
     <value>The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types.</value>
   </data>
diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
index a56c8e003f..c4ee014ad5 100644
--- a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
+++ b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
@@ -436,32 +436,48 @@ public abstract class JsonInputFormatterTestBase : LoggedTest
         Assert.IsType<TooManyModelErrorsException>(error.Exception);
     }
 
-    [Theory]
-    [InlineData("null", true, true)]
-    [InlineData("null", false, false)]
-    public async Task ReadAsync_WithInputThatDeserializesToNull_SetsModelOnlyIfAllowingEmptyInput(
-        string content,
-        bool treatEmptyInputAsDefaultValue,
-        bool expectedIsModelSet)
+    [Fact]
+    public async Task ReadAsync_WithNullJsonInput_WhenAllowingEmptyInput_SetsModelToNull()
     {
-        // Arrange
         var formatter = GetInputFormatter();
 
-        var contentBytes = Encoding.UTF8.GetBytes(content);
+        var contentBytes = Encoding.UTF8.GetBytes("null");
         var httpContext = GetHttpContext(contentBytes);
 
         var formatterContext = CreateInputFormatterContext(
             typeof(string),
             httpContext,
-            treatEmptyInputAsDefaultValue: treatEmptyInputAsDefaultValue);
+            treatEmptyInputAsDefaultValue: true);
 
-        // Act
         var result = await formatter.ReadAsync(formatterContext);
 
-        // Assert
         Assert.False(result.HasError);
-        Assert.Equal(expectedIsModelSet, result.IsModelSet);
+        Assert.True(result.IsModelSet);
+        Assert.Null(result.Model);
+    }
+
+    [Fact]
+    public async Task ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError()
+    {
+        var formatter = GetInputFormatter();
+
+        var contentBytes = Encoding.UTF8.GetBytes("null");
+        var httpContext = GetHttpContext(contentBytes);
+
+        var formatterContext = CreateInputFormatterContext(
+            typeof(string),
+            httpContext,
+            treatEmptyInputAsDefaultValue: false);
+
+        var result = await formatter.ReadAsync(formatterContext);
+
+        Assert.True(result.HasError);
+        Assert.False(result.IsModelSet);
         Assert.Null(result.Model);
+
+        var error = Assert.Single(formatterContext.ModelState[string.Empty].Errors);
+        Assert.Contains("null", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("non-empty", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
     }
 
     [Fact]
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
index 81e33dc609..4240ac3890 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
@@ -140,6 +140,7 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
         var successful = true;
         Exception? exception = null;
         object? model;
+        var hadJsonContent = false;
 
         using (var streamReader = context.ReaderFactory(readStream, encoding))
         {
@@ -159,6 +160,9 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
             try
             {
                 model = jsonSerializer.Deserialize(jsonReader, type);
+                // Track whether JSON content was actually read. A token type other
+                // than None indicates the reader parsed at least one JSON token.
+                hadJsonContent = jsonReader.TokenType != JsonToken.None;
             }
             finally
             {
@@ -177,10 +181,18 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
         {
             if (model == null && !context.TreatEmptyInputAsDefaultValue)
             {
-                // Some nonempty inputs might deserialize as null, for example whitespace,
-                // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-                // be notified that we don't regard this as a real input so it can register
-                // a model binding error.
+                if (hadJsonContent)
+                {
+                    // The request body contained a valid JSON value (e.g. the literal "null")
+                    // that deserialized to null, but the parameter is required.
+                    var modelName = context.ModelName;
+                    context.ModelState.TryAddModelError(
+                        modelName,
+                        Resources.FormatFormatExceptionMessage_NullJsonIsInvalid(modelName));
+
+                    return InputFormatterResult.Failure();
+                }
+
                 return InputFormatterResult.NoValue();
             }
             else
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
index 4887c8adbc..6e2d324938 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
@@ -141,4 +141,7 @@
   <data name="TempData_CannotSerializeType" xml:space="preserve">
     <value>The '{0}' cannot serialize an object of type '{1}'.</value>
   </data>
+  <data name="FormatExceptionMessage_NullJsonIsInvalid" xml:space="preserve">
+    <value>A JSON request body containing a null value is not valid for the required '{0}' parameter.</value>
+  </data>
 </root>
diff --git a/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs
index 0d399022c3..dbcd8bcd5a 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs
+++ b/src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs
@@ -212,12 +212,44 @@ public class NewtonsoftJsonInputFormatterTest : JsonInputFormatterTestBase
         return base.JsonFormatter_EscapedKeys_SingleQuote();
     }
 
-    [Theory]
-    [InlineData(" ", true, true)]
-    [InlineData(" ", false, false)]
-    public Task ReadAsync_WithInputThatDeserializesToNull_SetsModelOnlyIfAllowingEmptyInput_WhenValueIsWhitespaceString(string content, bool treatEmptyInputAsDefaultValue, bool expectedIsModelSet)
+    [Fact]
+    public async Task ReadAsync_WithWhitespaceInput_WhenAllowingEmptyInput_SetsModelToNull()
+    {
+        var formatter = GetInputFormatter();
+
+        var contentBytes = Encoding.UTF8.GetBytes(" ");
+        var httpContext = GetHttpContext(contentBytes);
+
+        var formatterContext = CreateInputFormatterContext(
+            typeof(string),
+            httpContext,
+            treatEmptyInputAsDefaultValue: true);
+
+        var result = await formatter.ReadAsync(formatterContext);
+
+        Assert.False(result.HasError);
+        Assert.True(result.IsModelSet);
+        Assert.Null(result.Model);
+    }
+
+    [Fact]
+    public async Task ReadAsync_WithWhitespaceInput_WhenNotAllowingEmptyInput_ReturnsNoValue()
     {
-        return base.ReadAsync_WithInputThatDeserializesToNull_SetsModelOnlyIfAllowingEmptyInput(content, treatEmptyInputAsDefaultValue, expectedIsModelSet);
+        var formatter = GetInputFormatter();
+
+        var contentBytes = Encoding.UTF8.GetBytes(" ");
+        var httpContext = GetHttpContext(contentBytes);
+
+        var formatterContext = CreateInputFormatterContext(
+            typeof(string),
+            httpContext,
+            treatEmptyInputAsDefaultValue: false);
+
+        var result = await formatter.ReadAsync(formatterContext);
+
+        Assert.False(result.HasError);
+        Assert.False(result.IsModelSet);
+        Assert.Null(result.Model);
     }
 
     [Theory]
Attempt 1: PASS

Attempt 1: BodyModelBinder Content-Length Detection

Approach

Fix the misleading "non-empty request body is required" error by modifying BodyModelBinder (not the formatters) to detect when the request body contained content that deserialized to null.

Key Difference from Attempt 0

  • Attempt 0: Changed formatters (SystemTextJsonInputFormatter, NewtonsoftJsonInputFormatter) to return Failure() with a new resource string error. Added hadJsonContent flag in Newtonsoft formatter.
  • This attempt: Formatters are unchanged — they still return NoValue(). The fix is entirely in BodyModelBinder, which checks HttpRequest.ContentLength > 0 in the NoValue handler to distinguish between an empty body and a body containing JSON null.

Changes Made

  1. src/Mvc/Mvc.Core/src/Resources.resx: Added new resource string ModelBinding_NullRequestBodyValueRequired = "The request body contained a null JSON value, which is not valid for a required parameter. A non-null value is required."
  2. src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs: In the NoValue handler (the else branch after checking result.HasError and result.IsModelSet), added a check for httpContext.Request.ContentLength > 0. If the body had content, uses the new null-specific message. Otherwise, falls back to the existing MissingRequestBodyRequiredValueAccessor() message.
  3. src/Mvc/Mvc.Core/test/ModelBinding/Binders/BodyModelBinderTests.cs: Added test BindModel_NullJsonInput_WhenNotAllowingEmptyInput_ProducesDistinctError that verifies the error message contains "null" and does NOT contain "non-empty" when the formatter returns NoValue and the request had ContentLength > 0.

Why This Approach

  • Minimal formatter changes: Zero modifications to SystemTextJsonInputFormatter or NewtonsoftJsonInputFormatter
  • Centralized logic: The error message selection is in one place (BodyModelBinder) rather than duplicated across formatters
  • Backward compatible: The MissingRequestBodyRequiredValueAccessor is still used for truly empty bodies
  • Testable: Uses ContentLength which is easily set in tests
📄 Diff
diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs
index 317178b1c3..176c483ee6 100644
--- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs
+++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/BodyModelBinder.cs
@@ -173,10 +173,23 @@ public partial class BodyModelBinder : IModelBinder
                 // If instead the input formatter wants to treat the input as optional, it must do so by
                 // returning InputFormatterResult.Success(defaultForModelType), because input formatters
                 // are responsible for choosing a default value for the model type.
-                var message = bindingContext
-                    .ModelMetadata
-                    .ModelBindingMessageProvider
-                    .MissingRequestBodyRequiredValueAccessor();
+                string message;
+                var requestContentLength = httpContext.Request.ContentLength;
+                if (requestContentLength > 0)
+                {
+                    // The request body had content (e.g. the JSON literal "null"), but the
+                    // formatter could not produce a model from it. Use a distinct message
+                    // so the caller knows the body was not empty.
+                    message = Resources.ModelBinding_NullRequestBodyValueRequired;
+                }
+                else
+                {
+                    message = bindingContext
+                        .ModelMetadata
+                        .ModelBindingMessageProvider
+                        .MissingRequestBodyRequiredValueAccessor();
+                }
+
                 bindingContext.ModelState.AddModelError(modelBindingKey, message);
             }
         }
diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx
index 6908f9047b..1af96f5666 100644
--- a/src/Mvc/Mvc.Core/src/Resources.resx
+++ b/src/Mvc/Mvc.Core/src/Resources.resx
@@ -300,6 +300,9 @@
   <data name="ModelBinding_MissingRequestBodyRequiredMember" xml:space="preserve">
     <value>A non-empty request body is required.</value>
   </data>
+  <data name="ModelBinding_NullRequestBodyValueRequired" xml:space="preserve">
+    <value>The request body contained a null JSON value, which is not valid for a required parameter. A non-null value is required.</value>
+  </data>
   <data name="ValueProviderResult_NoConverterExists" xml:space="preserve">
     <value>The parameter conversion from type '{0}' to type '{1}' failed because no type converter can convert between these types.</value>
   </data>
diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/BodyModelBinderTests.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/BodyModelBinderTests.cs
index 891ee2b889..2623aa6ecc 100644
--- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/BodyModelBinderTests.cs
+++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/BodyModelBinderTests.cs
@@ -155,6 +155,45 @@ public class BodyModelBinderTests
         Assert.Equal("Customized error message", entry.Value.Errors.Single().ErrorMessage);
     }
 
+    [Fact]
+    public async Task BindModel_NullJsonInput_WhenNotAllowingEmptyInput_ProducesDistinctError()
+    {
+        // Arrange
+        var mockInputFormatter = new Mock<IInputFormatter>();
+        mockInputFormatter.Setup(f => f.CanRead(It.IsAny<InputFormatterContext>()))
+            .Returns(true);
+        mockInputFormatter.Setup(o => o.ReadAsync(It.IsAny<InputFormatterContext>()))
+            .Returns(InputFormatterResult.NoValueAsync());
+        var inputFormatter = mockInputFormatter.Object;
+
+        var provider = new TestModelMetadataProvider();
+        provider.ForType<Person>().BindingDetails(d => d.BindingSource = BindingSource.Body);
+
+        var httpContext = new DefaultHttpContext();
+        httpContext.Request.ContentType = "application/json";
+        httpContext.Request.ContentLength = 4; // "null" is 4 bytes
+
+        var bindingContext = GetBindingContext(
+            typeof(Person),
+            httpContext,
+            metadataProvider: provider);
+        bindingContext.BinderModelName = "custom";
+
+        var binder = CreateBinder(new[] { inputFormatter });
+
+        // Act
+        await binder.BindModelAsync(bindingContext);
+
+        // Assert
+        Assert.False(bindingContext.Result.IsModelSet);
+        Assert.False(bindingContext.ModelState.IsValid);
+
+        var entry = Assert.Single(bindingContext.ModelState);
+        var errorMessage = entry.Value.Errors.Single().ErrorMessage;
+        Assert.Contains("null", errorMessage, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("non-empty", errorMessage, StringComparison.OrdinalIgnoreCase);
+    }
+
     [Theory]
     [InlineData(true)]
     [InlineData(false)]
Attempt 2: UNKNOWN

Attempt 2 - Alternative approach

Idea

Treat a literal JSON null token as an invalid input (when TreatEmptyInputAsDefaultValue is false) inside the JSON input formatters, and record a model-state error message that explicitly references null.

This avoids the misleading BodyModelBinder fallback message ("A non-empty request body is required."), which is appropriate for truly empty bodies, but not for a non-empty body containing the JSON token null.

Implementation details

  • Shared behavior: when the body deserializes to null and empty input is not allowed:

    • If the body was the JSON token null, add a model-state error using the existing message provider:
      • context.Metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor("null")
    • Return InputFormatterResult.Failure().
    • Otherwise (e.g., whitespace), keep returning InputFormatterResult.NoValue().
  • System.Text.Json formatter (SystemTextJsonInputFormatter):

    • STJ doesn't expose the first token when DeserializeAsync returns null, so for small payloads (<= 32 bytes) the formatter enables buffering and re-reads the body text to distinguish "null" from whitespace.
  • Newtonsoft formatter (NewtonsoftJsonInputFormatter):

    • Capture jsonReader.TokenType after deserialization and treat JsonToken.Null as the null-token case.

Tests

  • Added ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError in JsonInputFormatterTestBase.
  • Updated the existing null-input theory to reflect that "null" now yields HasError == true when empty input isn't allowed.
📄 Diff
diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
index 81c6571140..4314b21a6f 100644
--- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
@@ -3,6 +3,7 @@
 
 using System.Text;
 using System.Text.Json;
+using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Mvc.Formatters;
@@ -64,6 +65,17 @@ public partial class SystemTextJsonInputFormatter : TextInputFormatter, IInputFo
 
         var httpContext = context.HttpContext;
 
+        // Enable buffering for small payloads so we can distinguish between an empty request body and the JSON token 'null'.
+        if (!context.TreatEmptyInputAsDefaultValue &&
+            httpContext.Request.ContentLength is long contentLength &&
+            contentLength <= 32 &&
+            !httpContext.Request.Body.CanSeek)
+        {
+            httpContext.Request.EnableBuffering();
+        }
+
+        var startingPosition = httpContext.Request.Body.CanSeek ? httpContext.Request.Body.Position : 0;
+
         object? model;
         Stream? inputStream = null;
         try
@@ -118,8 +130,29 @@ public partial class SystemTextJsonInputFormatter : TextInputFormatter, IInputFo
         if (model == null && !context.TreatEmptyInputAsDefaultValue)
         {
             // Some nonempty inputs might deserialize as null, for example whitespace,
-            // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-            // be notified that we don't regard this as a real input so it can register
+            // or the JSON-encoded value "null".
+            if (httpContext.Request.Body.CanSeek)
+            {
+                var currentPosition = httpContext.Request.Body.Position;
+                httpContext.Request.Body.Position = startingPosition;
+
+                string? bodyText;
+                using (var reader = new StreamReader(httpContext.Request.Body, encoding, detectEncodingFromByteOrderMarks: false, leaveOpen: true))
+                {
+                    bodyText = await reader.ReadToEndAsync();
+                }
+
+                httpContext.Request.Body.Position = currentPosition;
+
+                if (string.Equals(bodyText.Trim(), "null", StringComparison.Ordinal))
+                {
+                    var message = context.Metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor("null");
+                    context.ModelState.TryAddModelError(string.Empty, message);
+                    return InputFormatterResult.Failure();
+                }
+            }
+
+            // Notify the upstream BodyModelBinder that we don't regard this as a real input so it can register
             // a model binding error.
             return InputFormatterResult.NoValue();
         }
diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
index a56c8e003f..52b032100c 100644
--- a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
+++ b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
@@ -459,9 +459,38 @@ public abstract class JsonInputFormatterTestBase : LoggedTest
         var result = await formatter.ReadAsync(formatterContext);
 
         // Assert
-        Assert.False(result.HasError);
-        Assert.Equal(expectedIsModelSet, result.IsModelSet);
-        Assert.Null(result.Model);
+        if (treatEmptyInputAsDefaultValue)
+        {
+            Assert.False(result.HasError);
+            Assert.Equal(expectedIsModelSet, result.IsModelSet);
+            Assert.Null(result.Model);
+        }
+        else
+        {
+            Assert.True(result.HasError);
+            Assert.False(result.IsModelSet);
+        }
+    }
+
+    [Fact]
+    public async Task ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError()
+    {
+        // Arrange
+        var formatter = GetInputFormatter();
+
+        var contentBytes = Encoding.UTF8.GetBytes("null");
+        var httpContext = GetHttpContext(contentBytes);
+
+        var formatterContext = CreateInputFormatterContext(typeof(string), httpContext, treatEmptyInputAsDefaultValue: false);
+
+        // Act
+        var result = await formatter.ReadAsync(formatterContext);
+
+        // Assert
+        Assert.True(result.HasError);
+        var error = Assert.Single(formatterContext.ModelState[string.Empty].Errors);
+        Assert.Contains("null", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("non-empty", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
     }
 
     [Fact]
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
index 81e33dc609..34ae7957c9 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
@@ -140,6 +140,7 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
         var successful = true;
         Exception? exception = null;
         object? model;
+        var tokenType = JsonToken.None;
 
         using (var streamReader = context.ReaderFactory(readStream, encoding))
         {
@@ -159,6 +160,7 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
             try
             {
                 model = jsonSerializer.Deserialize(jsonReader, type);
+                tokenType = jsonReader.TokenType;
             }
             finally
             {
@@ -178,8 +180,15 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
             if (model == null && !context.TreatEmptyInputAsDefaultValue)
             {
                 // Some nonempty inputs might deserialize as null, for example whitespace,
-                // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-                // be notified that we don't regard this as a real input so it can register
+                // or the JSON-encoded value "null".
+                if (tokenType == JsonToken.Null)
+                {
+                    var message = context.Metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor("null");
+                    context.ModelState.TryAddModelError(string.Empty, message);
+                    return InputFormatterResult.Failure();
+                }
+
+                // Notify the upstream BodyModelBinder that we don't regard this as a real input so it can register
                 // a model binding error.
                 return InputFormatterResult.NoValue();
             }
Attempt 3: PASS

Attempt 3 Approach

Use the formatter's existing model-binding message provider path for null values instead of returning NoValue.

  • In both JSON formatters, when deserialization returns null and empty input is not allowed, add a model-state error via ValueMustNotBeNullAccessor("null").
  • Return InputFormatterResult.Failure() so BodyModelBinder does not emit the generic non-empty-body error.
  • Add a formatter test asserting the null-specific message behavior.
📄 Diff
diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
index 81c6571140..771dd30ab7 100644
--- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
@@ -117,11 +117,10 @@ public partial class SystemTextJsonInputFormatter : TextInputFormatter, IInputFo
 
         if (model == null && !context.TreatEmptyInputAsDefaultValue)
         {
-            // Some nonempty inputs might deserialize as null, for example whitespace,
-            // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-            // be notified that we don't regard this as a real input so it can register
-            // a model binding error.
-            return InputFormatterResult.NoValue();
+            context.ModelState.TryAddModelError(
+                context.ModelName,
+                context.Metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor("null"));
+            return InputFormatterResult.Failure();
         }
         else
         {
diff --git a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
index a56c8e003f..ea84288c97 100644
--- a/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
+++ b/src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs
@@ -464,6 +464,25 @@ public abstract class JsonInputFormatterTestBase : LoggedTest
         Assert.Null(result.Model);
     }
 
+    [Fact]
+    public async Task ReadAsync_WithNullJsonInput_WhenNotAllowingEmptyInput_ReturnsFailureWithDistinctError()
+    {
+        // Arrange
+        var formatter = GetInputFormatter();
+        var contentBytes = Encoding.UTF8.GetBytes("null");
+        var httpContext = GetHttpContext(contentBytes);
+        var formatterContext = CreateInputFormatterContext(typeof(string), httpContext, treatEmptyInputAsDefaultValue: false);
+
+        // Act
+        var result = await formatter.ReadAsync(formatterContext);
+
+        // Assert
+        Assert.True(result.HasError);
+        var error = Assert.Single(Assert.Single(formatterContext.ModelState).Value.Errors);
+        Assert.Contains("null", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+        Assert.DoesNotContain("non-empty", error.ErrorMessage, StringComparison.OrdinalIgnoreCase);
+    }
+
     [Fact]
     public async Task ReadAsync_ComplexPoco()
     {
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
index 81e33dc609..a191cfd2cc 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
@@ -177,11 +177,10 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
         {
             if (model == null && !context.TreatEmptyInputAsDefaultValue)
             {
-                // Some nonempty inputs might deserialize as null, for example whitespace,
-                // or the JSON-encoded value "null". The upstream BodyModelBinder needs to
-                // be notified that we don't regard this as a real input so it can register
-                // a model binding error.
-                return InputFormatterResult.NoValue();
+                context.ModelState.TryAddModelError(
+                    context.ModelName,
+                    context.Metadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor("null"));
+                return InputFormatterResult.Failure();
             }
             else
             {

This approach avoids the misleading "non-empty request body" path by surfacing a null-value validation error directly from the input formatter. It reuses existing message-provider infrastructure (ValueMustNotBeNullAccessor) and does not require new APIs or content-length heuristics.

Attempt 4: UNKNOWN

Attempt 4: Override ReadAsync in formatters

Model: gemini-3-pro-preview

Approach

  • Overrode ReadAsync (not ReadRequestBodyAsync) in both formatters
  • The override detects when base ReadAsync returns NoValue but the request body was actually non-empty
  • In this case, adds a specific error to ModelState and returns Failure()
  • Added JsonInputFormatter_ValueMustNotBeNull resource string
  • Updated PublicAPI.Unshipped.txt for the new public API override

Key Difference from Other Attempts

  • Works at the ReadAsync level (wrapper around the base behavior) rather than inside ReadRequestBodyAsync
  • Adds a public API surface change (override of ReadAsync)

Files Changed

  • SystemTextJsonInputFormatter.cs (ReadAsync override)
  • NewtonsoftJsonInputFormatter.cs (ReadAsync override)
  • Resources.resx (both projects)
  • PublicAPI.Unshipped.txt (both projects)
📄 Diff
diff --git a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
index 81c6571140..998d70d504 100644
--- a/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs
@@ -3,6 +3,7 @@
 
 using System.Text;
 using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.Core;
 using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Mvc.Formatters;
@@ -54,6 +55,36 @@ public partial class SystemTextJsonInputFormatter : TextInputFormatter, IInputFo
     /// <inheritdoc />
     InputFormatterExceptionPolicy IInputFormatterExceptionPolicy.ExceptionPolicy => InputFormatterExceptionPolicy.MalformedInputExceptions;
 
+    /// <inheritdoc />
+    public override async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
+    {
+        var result = await base.ReadAsync(context);
+
+        if (result.HasError)
+        {
+            return result;
+        }
+
+        if (!result.IsModelSet && !context.TreatEmptyInputAsDefaultValue)
+        {
+            // If the model was not set and we are not treating empty input as default,
+            // check if the body was non-empty.
+            var canHaveBody = context.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>()?.CanHaveBody;
+            canHaveBody ??= context.HttpContext.Request.ContentLength != 0;
+
+            if (canHaveBody is true)
+            {
+                // The body was not empty, but the result is NoValue.
+                // This means the JSON input was "null".
+                var exception = new InputFormatterException(Resources.JsonInputFormatter_ValueMustNotBeNull);
+                context.ModelState.TryAddModelError(context.ModelName, exception, context.Metadata);
+                return InputFormatterResult.Failure();
+            }
+        }
+
+        return result;
+    }
+
     /// <inheritdoc />
     public sealed override async Task<InputFormatterResult> ReadRequestBodyAsync(
         InputFormatterContext context,
diff --git a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
index 7dc5c58110..45e7c009e6 100644
--- a/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
 #nullable enable
+override Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonInputFormatter.ReadAsync(Microsoft.AspNetCore.Mvc.Formatters.InputFormatterContext! context) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.Formatters.InputFormatterResult!>!
diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx
index 6908f9047b..f6d3eb9b27 100644
--- a/src/Mvc/Mvc.Core/src/Resources.resx
+++ b/src/Mvc/Mvc.Core/src/Resources.resx
@@ -516,4 +516,7 @@
   <data name="TryParseModelBinder_InvalidType" xml:space="preserve">
     <value>The type '{0}' does not contain a TryParse method and the binder '{1}' cannot be used.</value>
   </data>
+  <data name="JsonInputFormatter_ValueMustNotBeNull" xml:space="preserve">
+    <value>The request body must not be 'null'.</value>
+  </data>
 </root>
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj
index 36491ee736..3749a1b933 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/Microsoft.AspNetCore.Mvc.NewtonsoftJson.csproj
@@ -18,6 +18,8 @@
     <Reference Include="Microsoft.AspNetCore.Html.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Http" />
     <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
+    <Reference Include="Microsoft.AspNetCore.Http.Extensions" />
+    <Reference Include="Microsoft.AspNetCore.Http.Features" />
     <Reference Include="Microsoft.AspNetCore.JsonPatch" />
     <Reference Include="Microsoft.AspNetCore.Mvc.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Mvc.Core" />
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
index 81e33dc609..daa90dfa1c 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs
@@ -90,6 +90,36 @@ public partial class NewtonsoftJsonInputFormatter : TextInputFormatter, IInputFo
     /// </remarks>
     protected JsonSerializerSettings SerializerSettings { get; }
 
+    /// <inheritdoc />
+    public override async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
+    {
+        var result = await base.ReadAsync(context);
+
+        if (result.HasError)
+        {
+            return result;
+        }
+
+        if (!result.IsModelSet && !context.TreatEmptyInputAsDefaultValue)
+        {
+            // If the model was not set and we are not treating empty input as default,
+            // check if the body was non-empty.
+            var canHaveBody = context.HttpContext.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>()?.CanHaveBody;
+            canHaveBody ??= context.HttpContext.Request.ContentLength != 0;
+
+            if (canHaveBody is true)
+            {
+                // The body was not empty, but the result is NoValue.
+                // This means the JSON input was "null".
+                var exception = new InputFormatterException(Resources.JsonInputFormatter_ValueMustNotBeNull);
+                context.ModelState.TryAddModelError(context.ModelName, exception, context.Metadata);
+                return InputFormatterResult.Failure();
+            }
+        }
+
+        return result;
+    }
+
     /// <inheritdoc />
     public override async Task<InputFormatterResult> ReadRequestBodyAsync(
         InputFormatterContext context,
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt
index 7dc5c58110..e0c80f6111 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt
@@ -1 +1,2 @@
 #nullable enable
+override Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter.ReadAsync(Microsoft.AspNetCore.Mvc.Formatters.InputFormatterContext! context) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.Formatters.InputFormatterResult!>!
diff --git a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
index 4887c8adbc..8e18378ccb 100644
--- a/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
+++ b/src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx
@@ -141,4 +141,7 @@
   <data name="TempData_CannotSerializeType" xml:space="preserve">
     <value>The '{0}' cannot serialize an object of type '{1}'.</value>
   </data>
+  <data name="JsonInputFormatter_ValueMustNotBeNull" xml:space="preserve">
+    <value>The request body must not be 'null'.</value>
+  </data>
 </root>

…t body'

Fixes dotnet#40415

When a POST request with Content-Type: application/json has body containing
the literal JSON value 'null', both SystemTextJsonInputFormatter and
NewtonsoftJsonInputFormatter would return InputFormatterResult.NoValue().
BodyModelBinder then generated the error 'A non-empty request body is
required.' — misleading because the body IS non-empty (it contains valid JSON).

The fix changes the formatters to return InputFormatterResult.Failure() with
a distinct error message when the deserialized model is null and the parameter
does not allow empty input. The Newtonsoft formatter tracks whether the JSON
reader actually parsed tokens (hadJsonContent) to distinguish JSON null from
an exhausted/empty stream.

For nullable parameters, behavior is unchanged — EmptyBodyBehavior inference
(from .NET 7) already sets TreatEmptyInputAsDefaultValue=true, so JSON null
is accepted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kubaflo kubaflo requested a review from a team as a code owner March 1, 2026 19:22
Copilot AI review requested due to automatic review settings March 1, 2026 19:22
@github-actions github-actions bot added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Mar 1, 2026
@dotnet-policy-service dotnet-policy-service bot added the community-contribution Indicates that the PR has been added by a community member label Mar 1, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes misleading MVC model-binding behavior where a JSON request body containing the literal value null is treated as an “empty body”, by returning a formatter failure with a dedicated error message when the body is required.

Changes:

  • Add a new localized resource string for the “JSON null is invalid for required body” error.
  • Update both SystemTextJsonInputFormatter and NewtonsoftJsonInputFormatter to return InputFormatterResult.Failure() (with ModelState error) when deserialization results in null and empty input is not allowed.
  • Add/adjust unit tests covering null JSON behavior (and Newtonsoft whitespace handling).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Mvc/Mvc.Core/src/Resources.resx Adds new resource string for the distinct “JSON null is invalid” error.
src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs Returns Failure() with the new error message when required body deserializes to null.
src/Mvc/Mvc.NewtonsoftJson/src/Resources.resx Adds the same new resource string for NewtonsoftJson MVC package.
src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonInputFormatter.cs Adds hadJsonContent tracking and returns Failure() with the new error message for required null JSON.
src/Mvc/Mvc.Core/test/Formatters/JsonInputFormatterTestBase.cs Adds tests asserting distinct error behavior for required null JSON.
src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonInputFormatterTest.cs Adds targeted whitespace-input tests to preserve existing Newtonsoft behavior.

- Use context.Metadata.DisplayName/Name instead of context.ModelName
  for error message placeholder (ModelName is empty for top-level body binding)
- Change 'parameter' to 'parameter or property' since [FromBody] applies to both

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dotnet-policy-service
Copy link
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Mar 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates community-contribution Indicates that the PR has been added by a community member pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants