Skip to content

Treat FormatExceptions and OverflowExceptions to be treated as model state errors #15035

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

Merged
merged 1 commit into from
Oct 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters.Json;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Mvc.Formatters
Expand Down Expand Up @@ -87,6 +86,16 @@ public sealed override async Task<InputFormatterResult> ReadRequestBodyAsync(

return InputFormatterResult.Failure();
}
catch (Exception exception) when (exception is FormatException || exception is OverflowException)
{
// The code in System.Text.Json never throws these exceptions. However a custom converter could produce these errors for instance when
// parsing a value. These error messages are considered safe to report to users using ModelState.

context.ModelState.TryAddModelError(string.Empty, exception, context.Metadata);
Log.JsonInputException(_logger, exception);

return InputFormatterResult.Failure();
}
finally
{
if (inputStream is TranscodingReadStream transcoding)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ public virtual async Task ReadAsync_InvalidArray_AddsOverflowErrorsToModelState(
// Assert
Assert.True(result.HasError, "Model should have produced an error!");
Assert.Collection(formatterContext.ModelState.OrderBy(k => k.Key),
kvp => {
kvp =>
{
Assert.Equal(expectedValue, kvp.Key);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Xunit;
Expand Down Expand Up @@ -81,6 +83,48 @@ public async Task ReadAsync_SingleError()
});
}

[Fact]
public async Task ReadAsync_DoesNotThrowFormatException()
{
// Arrange
var formatter = GetInputFormatter();

var contentBytes = Encoding.UTF8.GetBytes("{\"dateValue\":\"not-a-date\"}");
var httpContext = GetHttpContext(contentBytes);

var formatterContext = CreateInputFormatterContext(typeof(TypeWithBadConverters), httpContext);

// Act
await formatter.ReadAsync(formatterContext);

Assert.False(formatterContext.ModelState.IsValid);
var kvp = Assert.Single(formatterContext.ModelState);
Assert.Empty(kvp.Key);
var error = Assert.Single(kvp.Value.Errors);
Assert.Equal("The supplied value is invalid.", error.ErrorMessage);
}

[Fact]
public async Task ReadAsync_DoesNotThrowOverflowException()
{
// Arrange
var formatter = GetInputFormatter();

var contentBytes = Encoding.UTF8.GetBytes("{\"shortValue\":\"32768\"}");
var httpContext = GetHttpContext(contentBytes);

var formatterContext = CreateInputFormatterContext(typeof(TypeWithBadConverters), httpContext);

// Act
await formatter.ReadAsync(formatterContext);

Assert.False(formatterContext.ModelState.IsValid);
var kvp = Assert.Single(formatterContext.ModelState);
Assert.Empty(kvp.Key);
var error = Assert.Single(kvp.Value.Errors);
Assert.Equal("The supplied value is invalid.", error.ErrorMessage);
}

protected override TextInputFormatter GetInputFormatter()
{
return new SystemTextJsonInputFormatter(new JsonOptions(), LoggerFactory.CreateLogger<SystemTextJsonInputFormatter>());
Expand All @@ -99,5 +143,40 @@ protected override TextInputFormatter GetInputFormatter()
internal override string ReadAsync_InvalidComplexArray_AddsOverflowErrorsToModelState_Expected => "$[1].Small";

internal override string ReadAsync_ComplexPoco_Expected => "$.Person.Numbers[2]";

private class TypeWithBadConverters
{
[JsonConverter(typeof(DateTimeConverter))]
public DateTime DateValue { get; set; }

[JsonConverter(typeof(ShortConverter))]
public short ShortValue { get; set; }
}

private class ShortConverter : JsonConverter<short>
{
public override short Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return short.Parse(reader.GetString());
}

public override void Write(Utf8JsonWriter writer, short value, JsonSerializerOptions options)
{
throw new NotImplementedException();
}
}

private class DateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return DateTime.Parse(reader.GetString());
}

public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
throw new NotImplementedException();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,15 @@ public override async Task<InputFormatterResult> ReadRequestBodyAsync(
}
}

if (!(exception is JsonException || exception is OverflowException))
if (!(exception is JsonException || exception is OverflowException || exception is FormatException))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I dug around and OverflowException made the list because there was a test that broke if it wasn't allowed. Since it looks like an oversight, I don't think there's a compelling reason to put this behind a compat flag. The SystemTextJson one was a similar oversight.

Thoughts?

{
// At this point we've already recorded all exceptions as an entry in the ModelStateDictionary.
// We only need to rethrow an exception if we believe it needs to be handled by something further up
// the stack.
// JsonException, OverflowException, and FormatException are assumed to be only encountered when
// parsing the JSON and are consequently "safe" to be exposed as part of ModelState. Everything else
// needs to be rethrown.

var exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception);
exceptionDispatchInfo.Throw();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@
using Microsoft.Extensions.ObjectPool;
using Moq;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using Xunit;

namespace Microsoft.AspNetCore.Mvc.Formatters
{
public class NewtonsoftJsonInputFormatterTest : JsonInputFormatterTestBase
{
private static readonly ObjectPoolProvider _objectPoolProvider = new DefaultObjectPoolProvider();
private static readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings();
private readonly ObjectPoolProvider _objectPoolProvider = new DefaultObjectPoolProvider();
private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings();

[Fact]
public async Task Constructor_BuffersRequestBody_UsingDefaultOptions()
Expand Down Expand Up @@ -144,7 +145,7 @@ public void Constructor_UsesSerializerSettings()
var serializerSettings = new JsonSerializerSettings();

// Act
var formatter = new TestableJsonInputFormatter(serializerSettings);
var formatter = new TestableJsonInputFormatter(serializerSettings, _objectPoolProvider);

// Assert
Assert.Same(serializerSettings, formatter.SerializerSettings);
Expand Down Expand Up @@ -185,7 +186,7 @@ public void CreateJsonSerializer_UsesJsonSerializerSettings()
MaxDepth = 2,
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind,
};
var formatter = new TestableJsonInputFormatter(settings);
var formatter = new TestableJsonInputFormatter(settings, _objectPoolProvider);

// Act
var actual = formatter.CreateJsonSerializer(null);
Expand Down Expand Up @@ -304,7 +305,7 @@ public async Task ReadAsync_DoNotAllowInputFormatterExceptionMessages_DoesNotWra
}

[Fact]
public async Task ReadAsync_lowInputFormatterExceptionMessages_DoesNotWrapJsonInputExceptions()
public async Task ReadAsync_AllowInputFormatterExceptionMessages_DoesNotWrapJsonInputExceptions()
{
// Arrange
var formatter = new NewtonsoftJsonInputFormatter(
Expand Down Expand Up @@ -336,10 +337,72 @@ public async Task ReadAsync_lowInputFormatterExceptionMessages_DoesNotWrapJsonIn
Assert.NotEmpty(modelError.ErrorMessage);
}

[Fact]
public async Task ReadAsync_DoesNotRethrowFormatExceptions()
{
// Arrange
_serializerSettings.Converters.Add(new IsoDateTimeConverter());

var formatter = new NewtonsoftJsonInputFormatter(
GetLogger(),
_serializerSettings,
ArrayPool<char>.Shared,
_objectPoolProvider,
new MvcOptions(),
new MvcNewtonsoftJsonOptions());

var contentBytes = Encoding.UTF8.GetBytes("{\"dateValue\":\"not-a-date\"}");
var httpContext = GetHttpContext(contentBytes);

var formatterContext = CreateInputFormatterContext(typeof(TypeWithPrimitives), httpContext);

// Act
var result = await formatter.ReadAsync(formatterContext);

// Assert
Assert.True(result.HasError);
Assert.False(formatterContext.ModelState.IsValid);

var modelError = Assert.Single(formatterContext.ModelState["dateValue"].Errors);
Assert.Null(modelError.Exception);
Assert.Equal("The supplied value is invalid.", modelError.ErrorMessage);
}

[Fact]
public async Task ReadAsync_DoesNotRethrowOverflowExceptions()
{
// Arrange
_serializerSettings.Converters.Add(new IsoDateTimeConverter());

var formatter = new NewtonsoftJsonInputFormatter(
GetLogger(),
_serializerSettings,
ArrayPool<char>.Shared,
_objectPoolProvider,
new MvcOptions(),
new MvcNewtonsoftJsonOptions());

var contentBytes = Encoding.UTF8.GetBytes("{\"shortValue\":\"32768\"}");
var httpContext = GetHttpContext(contentBytes);

var formatterContext = CreateInputFormatterContext(typeof(TypeWithPrimitives), httpContext);

// Act
var result = await formatter.ReadAsync(formatterContext);

// Assert
Assert.True(result.HasError);
Assert.False(formatterContext.ModelState.IsValid);

var modelError = Assert.Single(formatterContext.ModelState["shortValue"].Errors);
Assert.Null(modelError.Exception);
Assert.Equal("The supplied value is invalid.", modelError.ErrorMessage);
}

private class TestableJsonInputFormatter : NewtonsoftJsonInputFormatter
{
public TestableJsonInputFormatter(JsonSerializerSettings settings)
: base(GetLogger(), settings, ArrayPool<char>.Shared, _objectPoolProvider, new MvcOptions(), new MvcNewtonsoftJsonOptions())
public TestableJsonInputFormatter(JsonSerializerSettings settings, ObjectPoolProvider objectPoolProvider)
: base(GetLogger(), settings, ArrayPool<char>.Shared, objectPoolProvider, new MvcOptions(), new MvcNewtonsoftJsonOptions())
{
}

Expand Down Expand Up @@ -418,5 +481,26 @@ private sealed class UserLogin
[JsonProperty(Required = Required.Always)]
public string Password { get; set; }
}

public class TypeWithPrimitives
{
public DateTime DateValue { get; set; }

[JsonConverter(typeof(IncorrectShortConverter))]
public short ShortValue { get; set; }
}

private class IncorrectShortConverter : JsonConverter<short>
{
public override short ReadJson(JsonReader reader, Type objectType, short existingValue, bool hasExistingValue, JsonSerializer serializer)
{
return short.Parse(reader.Value.ToString());
}

public override void WriteJson(JsonWriter writer, short value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
}
}
}