Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSON "null" request body rejected as an empty request body #41002

Closed
wants to merge 5 commits into from

Conversation

senbar
Copy link
Contributor

@senbar senbar commented Apr 1, 2022

JSON "null" response body rejected as an empty response body

I have added check if request body has 0 bytes; If yes then body was actually empty and proper error message is returned; If not then json contained null and different message is returned.

Should I create new error message for this null case in resources? I have changed it to ValueMustNotBeNullAccessor but it doesn't seem appropriate since it was for a bit different cases elsewhere.

Fixes #40415

@senbar senbar requested a review from a team as a code owner April 1, 2022 13:05
@ghost ghost added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates community-contribution Indicates that the PR has been added by a community member labels Apr 1, 2022
@senbar senbar marked this pull request as draft April 1, 2022 13:13
remove null coalescing since bodylength is null for null value in json
@senbar
Copy link
Contributor Author

senbar commented Apr 6, 2022

I think this should do it. If anyone has better idea how to check whether request was empty or with null value please let me know. Also the error message is up for debate I dont think the one I used is clear at all

@senbar senbar marked this pull request as ready for review April 6, 2022 10:14
@TanayParikh TanayParikh added area-web-frameworks *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels and removed area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates labels Apr 6, 2022
bindingContext.ModelState.AddModelError(modelBindingKey, message);

// If request content length is 0 then request had empty body, if not null was provided in json
if (httpContext.Request.ContentLength == 0)
Copy link
Member

Choose a reason for hiding this comment

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

I don't feel that this is the right place to do this. Maybe an option is change the json-based inpur formatters to AddModelError and return Failure instead

return InputFormatterResult.NoValue();

return InputFormatterResult.NoValue();

Please check if we do have any side effects with this change.

Copy link
Member

@halter73 halter73 Apr 11, 2022

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@senbar senbar Apr 13, 2022

Choose a reason for hiding this comment

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

Thanks for feedback! I agree with suggestions and am working on them

@senbar
Copy link
Contributor Author

senbar commented Apr 24, 2022

So I think there should be some sort of ModelState enum in InputFormatterResult class indicating whether model is set null or undefined.

@senbar senbar requested a review from a team as a code owner April 27, 2022 18:28
@senbar
Copy link
Contributor Author

senbar commented Apr 27, 2022

I made commit with changes that I think would improve API here. I have added enum describing state of model binding (set/null/empty body). All the logic checking if model is in these states is broken so don't look into that. I haven't made any api changes proposal yet so I dont entirely know how the process goes. That's why I would appreciate here feedback if this is the way I should finish this issue.

@senbar senbar requested a review from brunolins16 April 27, 2022 18:34
@brunolins16
Copy link
Member

I made commit with changes that I think would improve API here. I have added enum describing state of model binding (set/null/empty body). All the logic checking if model is in these states is broken so don't look into that. I haven't made any api changes proposal yet so I dont entirely know how the process goes. That's why I would appreciate here feedback if this is the way I should finish this issue.

@senbar That is an interesting change, however, as you mentioned that will be a large change to how the states are checked. Since the goal is to just provide an more descriptive error message, for JSON "null", but keep rejecting the request as before. I still feel you could try something easier.

If you take a look where the ReadRequestBodyAsync is called (here), it is only called when the body is detected that means the SystemTextJsonInputFormatter or NewtonsoftJsonInputFormatter will only do the serialization when a body is detected. With that, both formatters will do the serialization and check if the model is null and report back the NoValue state, that is where I feel we can do better, something like

if (model == null && !context.TreatEmptyInputAsDefaultValue)
{
    if ( [check for JSON-encoded value "null"]) 
    { 
        bindingContext.ModelState.AddModelError(context.ModelName, "The new message");
        return InputFormatterResult.Failure();
    } 

    // Some nonempty inputs might deserialize as null, for example whitespace. 
    // 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();
}

@senbar
Copy link
Contributor Author

senbar commented May 5, 2022

To check if request is null here would need reading Request.Body stream in

if (model == null && !context.TreatEmptyInputAsDefaultValue)
{
        var bodyStreamReader = new StreamReader(httpContext.Request.Body);
        bodyStreamReader.BaseStream.Seek(0, SeekOrigin.Begin);
        string rawJson = await bodyStreamReader.ReadToEndAsync();
        if(rawJson=="null")
        {
            context.ModelState.TryAddModelError(context.ModelName, "null value not supported");
            return InputFormatterResult.Failure();
        }

        // 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();
}

which doesn't work because Request.Body stream is not seekable in general. Any ideas what should I do instead? Buffering whole body stream would have big performance impact.

@halter73 halter73 changed the title JSON "null" response body rejected as an empty response body JSON "null" request body rejected as an empty request body May 5, 2022
@halter73
Copy link
Member

halter73 commented May 5, 2022

With that, both formatters will do the serialization and check if the model is null and report back the NoValue state, that is where I feel we can do better, something like

if (model == null && !context.TreatEmptyInputAsDefaultValue)
{
    if ( [check for JSON-encoded value "null"]) 
    { 
        bindingContext.ModelState.AddModelError(context.ModelName, "The new message");
        return InputFormatterResult.Failure();
    } 

    // Some nonempty inputs might deserialize as null, for example whitespace. 
    // 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();
}

I'm not sure we want to report failure at such a low layer. After all, null is one of the "nonempty inputs might deserialize as null" that the BodyModelBinder needs to know about. I don't think that either !context.TreatEmptyInputAsDefaultValue nor EmptyBodyBehavior.Disallow implies that a null literal should be rejected outright. It should be if the parameter is non-nullable, but we don't know that at the InputFormatter layer do we?

We probably should be changing this to return InputFormatterResult.Success(null); for null literals instead of InputFormatterResult.NoValue(); regardless of TreatEmptyInputAsDefaultValue because it's not truly empty.

It's a literal default value, and the whole problem here seems to be that both empty input and null literals are being collapsed into a single NoValue() when TreatEmptyInputAsDefaultValue is false. It's fine for these values to be collapsed when TreatEmptyInputAsDefaultValue is true, because that's what you asked for, but not the other way around.

I don't think it's necessary to check if there was an empty request body or null because the method you're modifying, ReadRequestBodyAsync, is only called when the body is non-empty (or at least we think it will be) that what the canHaveBody logic I linked to earlier was doing. It was deciding whether to call ReadRequestBodyAsync or return NoValue() immediately:

var canHaveBody = context.HttpContext.Features.Get<IHttpRequestBodyDetectionFeature>()?.CanHaveBody;
// In case the feature is not registered
canHaveBody ??= context.HttpContext.Request.ContentLength != 0;
if (canHaveBody is false)
{
if (context.TreatEmptyInputAsDefaultValue)
{
return InputFormatterResult.SuccessAsync(GetDefaultValueForType(context.ModelType));
}
return InputFormatterResult.NoValueAsync();
}
return ReadRequestBodyAsync(context);

And if the canHaveBody logic was wrong, and we thought there was going to be a body but it turned out to be empty, JsonSerializer.DeserializeAsync() in SystemTextJsonInputFormatter will throw a JsonException which will always be turned into a Failure().

Unfortunately, Json.NET turns an empty body into null when you try to deserialize. Again the canHaveBody checks should prevent this from happening for most empty bodies but not all. I assume we copied the if (model == null && !context.TreatEmptyInputAsDefaultValue) logic from the NewtonsoftJsonInputFormatter to the SystemTextJsonInputFormatter because it makes very little sense in the latter case where it will only return null for a null literal and nothing else.

@brunolins16 Is there anything wrong with just removing the if (model == null && !context.TreatEmptyInputAsDefaultValue) branch entirely SystemTextJsonInputFormatter and always return InputFormatterResult.Success(model); when JsonSerializer.DeserializeAsync() doesn't throw?

I imagine we'll have to be more careful with the Json.NET input formatter for back compat. It's always reliably treated null and empty bodies as exactly the same because they were indistinguishable from just the call to Deserialize. We could in theory try to test if any bytes were actually read from the body stream after the fact to determine the difference, but that would be a breaking change.

@senbar
Copy link
Contributor Author

senbar commented May 5, 2022

So isn't this a problem with entire handling of model binding of "null" json on nullable parameter with TreatEmptyInputAsDefaultValue==true? If we get "null" json it gets deserialized to null model which returns
NoValue from formatter. This in turn leads to model binding error since:

// If the input formatter gives a "no value" result, that's always a model state error,

From what I understand TreatEmptyInputAsDefaultValue refers to empty request body not json "null" literal right?

It should be if the parameter is non-nullable, but we don't know that at the InputFormatter layer do we?

But we do know the type of deserialized object since we are getting it from context.ModelType for deserialization in InputFormatter right? Also the same comment I quoted earlier states:

// If the input formatter gives a "no value" result, that's always a model state error,
// because BodyModelBinder implicitly regards input as being required for model binding.
// 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.

So I think it is implied that formatter should be handling dealing with optional arguments and stuff like that.

As for checking if json is null literal I was thinking about the case described in the comment:
// Some nonempty inputs might deserialize as null, for example whitespace, (...)
so I wanted to check if the input we got is actually a null literal or some whitespace. From what I can see know whitespace actually makes deserialization throw "The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. Path: $ | LineNumber: 0 | BytePositionInLine: 1." . Since the comment made it clear there are some inputs being regarded as null I wanted to make sure the json is null literal (which I am not entirely sure how to do since request stream is of course non seekable).

@brunolins16
Copy link
Member

brunolins16 commented May 5, 2022

So isn't this a problem with entire handling of model binding of "null" json on nullable parameter with TreatEmptyInputAsDefaultValue==true?

I do not think that the condition is wrong just because we did some improvements (.NET 7) in how we are treating the AllowEmptyBody based on the Nullabity in I feel only very specific scenarios will allow empty body and not null or vice versa.

@brunolins16 Bruno Lins de Oliveira FTE Is there anything wrong with just removing the if (model == null && !context.TreatEmptyInputAsDefaultValue) branch entirely SystemTextJsonInputFormatter and always return InputFormatterResult.Success(model); when JsonSerializer.DeserializeAsync() doesn't throw?

@halter73 As we discussed, "null" and empty body are different, however, MVC treat them as the same for STJ for a long time and change might cause some breaking change. We changed to make the empty body better and most of the cases when a "null" is accepted will be covered by this new mechanism, as example:

public ActionResult([FromBody] Contact? contact) {}

or

public ActionResult([FromBody] Contact contact = null) {}

Until .NET 6 they used to be blocked for both (null and empty body) because we were not using the optionality to allow them. Now, .NET 7, both actions will be accepted (null and empty body).

Since the mechanism will behave differently, as I mentioned, not change it right now might be the best and going back to the original idea of just update the error message to differentiate between Empty Body and null is a less breaking change.

Since the comment made it clear there are some inputs being regarded as null I wanted to make sure the json is null literal (which I am not entirely sure how to do since request stream is of course non seekable).

I think the comment about whitespace is not true, as you mentioned, and the only situation where we will get a null value from the System.Text.Json will be when we have a literal null, so i feel we can safe say (only for System.Text.Json) this:

if (model == null && !context.TreatEmptyInputAsDefaultValue)
{
            context.ModelState.TryAddModelError(context.ModelName, "null value not supported");
            return InputFormatterResult.Failure();
}

That will produce a better error message, without change the current behavior. And the new mechanism or the user option to allow empty body will be used to decide if the failure will happen or we will treat as Success binding.

Also, I think we should keep the NewtonsoftJsonInputFormatter as-is for now.

@halter73 anything to add based on our discussion?
@senbar is that make sense for you?

@senbar
Copy link
Contributor Author

senbar commented May 6, 2022

I am not sure if I understand correctly how it deals with nullable types. When I run test with (/contact is simple endpoint with Contact class object argument from body):

 var response = await Client.PostAsJsonAsync<Contact>("/contact", default);

model binding returns error even though Contact is nullable and null literal was provided.
The reason is I as I mentioned above: model gets set to null and SystemTextJsonInputFormatter returns NoValue which binder interprets as an error. Isn't this incorrect or do I misunderstand behaviour we are going for?

@brunolins16
Copy link
Member

I am not sure if I understand correctly how it deals with nullable types. When I run test with (/contact is simple endpoint with Contact class object argument from body):

 var response = await Client.PostAsJsonAsync<Contact>("/contact", default);

model binding returns error even though Contact is nullable and null literal was provided. The reason is I as I mentioned above: model gets set to null and SystemTextJsonInputFormatter returns NoValue which binder interprets as an error. Isn't this incorrect or do I misunderstand behaviour we are going for?

We changed the behavior in .NET 7 and a nullable Contact will set the TreatEmptyInputAsDefaultValue as true. So, a null literal and empty body will be accepted.

remove logic from BodyModelBinder;
Fix messgaes on ApiBehaviortest
@senbar
Copy link
Contributor Author

senbar commented May 18, 2022

@brunolins16 I have made suggested changes to SytemTextJsonInputFormatter and added tests for it.
The question is should I do something with ApiBehaviorTest since it currently relies on SystemTextJsonInputFormatter being used (my null prarameter test would fail if Json.net was used). This may not be the best practise in functional test.

Edit: I see some tests are failing, will fix that shortly

@brunolins16
Copy link
Member

@brunolins16 I have made suggested changes to SytemTextJsonInputFormatter and added tests for it. The question is should I do something with ApiBehaviorTest since it currently relies on SystemTextJsonInputFormatter being used (my null prarameter test would fail if Json.net was used). This may not be the best practise in functional test

Let me do some investigation and will reply to you soon. Thanks for your patience and contribution.

@brunolins16
Copy link
Member

@senbar I just took a look and i think the easiest way is to add the new Functional Tests to https://github.com/brunolins16/aspnetcore/blob/main/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs , instead APIBehaviorTests, similar to what we have here:

https://github.com/brunolins16/aspnetcore/blob/e9b9fc0c533b120ce5c0317ee8e3a56aca26d37d/src/Mvc/test/Mvc.FunctionalTests/NewtonsoftJsonInputFormatterTest.cs#L35

// 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, "null value not supported");
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about something like The supplied value 'null' is invalid or The value 'null' is invalid?

@@ -86,6 +86,28 @@ public async Task ReadAsync_SingleError()
});
}

[Fact]
public async Task ReadAsync_WithJsonNullLiteralWithoutTreatEmptyInputAsDefault_ShouldSetNullValueNotSupportedError()
Copy link
Member

Choose a reason for hiding this comment

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

Maybe you could add a test with treatEmptyInputAsDefaultValue = true?

var httpContext = GetHttpContext(contentBytes);

var formatterContext = CreateInputFormatterContext(typeof(int?), httpContext);
// TreatEmptyInputAsDefaultValue is set to false so even if type is nullable we should gget 'null value not supported'
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// TreatEmptyInputAsDefaultValue is set to false so even if type is nullable we should gget 'null value not supported'
// TreatEmptyInputAsDefaultValue is set to false so even if type is nullable the formatter should report a failure.

}

[Fact]
public virtual async Task ActionWithNonOptionalParameterReturnsBodyCannotBeEmpty_WhenInvokedWithEmptyBody()
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public virtual async Task ActionWithNonOptionalParameterReturnsBodyCannotBeEmpty_WhenInvokedWithEmptyBody()
public Task JsonInputFormatter_ReturnsBadRequest_ForEmptyRequestBodyAction()

Copy link
Member

Choose a reason for hiding this comment

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

@@ -82,6 +83,73 @@ public virtual async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
}
}

[Fact]
public virtual async Task ActionWithNonOptionalParameterReturnsNullIsNotPermited_WhenInvokedWithNullParameter()
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public virtual async Task ActionWithNonOptionalParameterReturnsNullIsNotPermited_WhenInvokedWithNullParameter()
public Task JsonInputFormatter_ReturnsBadRequest_ForLiteralNull()

@senbar
Copy link
Contributor Author

senbar commented Jun 10, 2022

Sorry have been busy last weeks, I will be finishing PR soon

@brunolins16
Copy link
Member

Sorry have been busy last weeks, I will be finishing PR soon

@senbar are you still planning to work on it? Can I help you on anything?

@senbar
Copy link
Contributor Author

senbar commented Aug 3, 2022

Yeah sorry aobut that I was changing job lately and was swamped with work. I will try to finish it next two weeks. As for help I think I got it it should be pretty straightforward, if there are any problems I will let You know, thanks

@brunolins16
Copy link
Member

Don't worry about it, take your time. I was just checking if I could do anything to help.

@captainsafia captainsafia added the pr: pending author input For automation. Specifically separate from Needs: Author Feedback label Dec 6, 2022
@ghost
Copy link

ghost commented Dec 19, 2022

Hi @senbar.
It seems you haven't touched this PR for the last two weeks. To avoid accumulating old PRs, we're marking it as stale. As a result, it will be closed if no further activity occurs within 4 days of this comment. You can learn more about our Issue Management Policies here.

@ghost ghost added the stale Indicates a stale issue. These issues will be closed automatically soon. label Dec 19, 2022
@ghost ghost closed this Dec 23, 2022
This pull request was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-web-frameworks *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels community-contribution Indicates that the PR has been added by a community member pr: pending author input For automation. Specifically separate from Needs: Author Feedback stale Indicates a stale issue. These issues will be closed automatically soon.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

JSON "null" request body rejected as an empty request body
6 participants