Skip to content
This repository was archived by the owner on Dec 14, 2018. It is now read-only.

Commit d9825d1

Browse files
Better JSON deserialization errors. Implements #4607, #4862
1 parent ab4c519 commit d9825d1

File tree

17 files changed

+257
-32
lines changed

17 files changed

+257
-32
lines changed

src/Microsoft.AspNetCore.Mvc.Abstractions/ModelBinding/ModelStateDictionary.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Diagnostics;
88
using System.Runtime.CompilerServices;
99
using Microsoft.AspNetCore.Mvc.Abstractions;
10+
using Microsoft.AspNetCore.Mvc.Formatters;
1011
using Microsoft.Extensions.Primitives;
1112

1213
namespace Microsoft.AspNetCore.Mvc.ModelBinding
@@ -263,6 +264,11 @@ public bool TryAddModelError(string key, Exception exception, ModelMetadata meta
263264

264265
return TryAddModelError(key, errorMessage);
265266
}
267+
else if (exception is InputFormatterException && !string.IsNullOrEmpty(exception.Message))
268+
{
269+
// InputFormatterException is a signal that the message is safe to expose to clients
270+
return TryAddModelError(key, exception.Message);
271+
}
266272

267273
ErrorCount++;
268274
AddModelErrorCore(key, exception);

src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,20 @@ public int MaxModelValidationErrors
170170
public bool AllowBindingUndefinedValueToEnumType { get; set; }
171171

172172
/// <summary>
173-
/// Gets or sets the option to determine if model binding should convert all exceptions(including ones not related to bad input)
173+
/// Gets or sets the option to determine if model binding should convert all exceptions (including ones not related to bad input)
174174
/// that occur during deserialization in <see cref="IInputFormatter"/>s into model state errors.
175175
/// This option applies only to custom <see cref="IInputFormatter"/>s.
176176
/// Default is <see cref="InputFormatterExceptionModelStatePolicy.AllExceptions"/>.
177177
/// </summary>
178178
public InputFormatterExceptionModelStatePolicy InputFormatterExceptionModelStatePolicy { get; set; }
179+
180+
/// <summary>
181+
/// Gets or sets a flag to determine whether, if an action receives invalid JSON in
182+
/// the request body, the JSON deserialization exception message should be replaced
183+
/// by a generic error message in model state.
184+
/// <see langword="false"/> by default, meaning that clients may receive details about
185+
/// why the JSON they posted is considered invalid.
186+
/// </summary>
187+
public bool SuppressJsonDeserializationExceptionMessagesInModelState { get; set; } = false;
179188
}
180189
}

src/Microsoft.AspNetCore.Mvc.Core/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.Runtime.CompilerServices;
5+
using Microsoft.AspNetCore.Mvc.Formatters;
56

67
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Core.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
78
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
9+
[assembly: TypeForwardedTo(typeof(InputFormatterException))]

src/Microsoft.AspNetCore.Mvc.Formatters.Json/Internal/MvcJsonMvcOptionsSetup.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,17 @@ public void Configure(MvcOptions options)
6969
_jsonSerializerSettings,
7070
_charPool,
7171
_objectPoolProvider,
72-
options.SuppressInputFormatterBuffering));
72+
options.SuppressInputFormatterBuffering,
73+
options.SuppressJsonDeserializationExceptionMessagesInModelState));
7374

7475
var jsonInputLogger = _loggerFactory.CreateLogger<JsonInputFormatter>();
7576
options.InputFormatters.Add(new JsonInputFormatter(
7677
jsonInputLogger,
7778
_jsonSerializerSettings,
7879
_charPool,
7980
_objectPoolProvider,
80-
options.SuppressInputFormatterBuffering));
81+
options.SuppressInputFormatterBuffering,
82+
options.SuppressJsonDeserializationExceptionMessagesInModelState));
8183

8284
options.FormatterMappings.SetMediaTypeMappingForFormat("json", MediaTypeHeaderValue.Parse("application/json"));
8385

src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonInputFormatter.cs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class JsonInputFormatter : TextInputFormatter, IInputFormatterExceptionPo
2828
private readonly ILogger _logger;
2929
private readonly ObjectPoolProvider _objectPoolProvider;
3030
private readonly bool _suppressInputFormatterBuffering;
31+
private readonly bool _suppressJsonDeserializationExceptionMessages;
3132

3233
private ObjectPool<JsonSerializer> _jsonSerializerPool;
3334

@@ -70,6 +71,32 @@ public JsonInputFormatter(
7071
ArrayPool<char> charPool,
7172
ObjectPoolProvider objectPoolProvider,
7273
bool suppressInputFormatterBuffering)
74+
: this(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering, suppressJsonDeserializationExceptionMessages: false)
75+
{
76+
// This constructor by default treats JSON deserialization exceptions as safe
77+
// because this is the default for applications generally
78+
}
79+
80+
/// <summary>
81+
/// Initializes a new instance of <see cref="JsonInputFormatter"/>.
82+
/// </summary>
83+
/// <param name="logger">The <see cref="ILogger"/>.</param>
84+
/// <param name="serializerSettings">
85+
/// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
86+
/// (<see cref="MvcJsonOptions.SerializerSettings"/>) or an instance
87+
/// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
88+
/// </param>
89+
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
90+
/// <param name="objectPoolProvider">The <see cref="ObjectPoolProvider"/>.</param>
91+
/// <param name="suppressInputFormatterBuffering">Flag to buffer entire request body before deserializing it.</param>
92+
/// <param name="suppressJsonDeserializationExceptionMessages">If <see langword="true"/>, JSON deserialization exception messages will replaced by a generic message in model state.</param>
93+
public JsonInputFormatter(
94+
ILogger logger,
95+
JsonSerializerSettings serializerSettings,
96+
ArrayPool<char> charPool,
97+
ObjectPoolProvider objectPoolProvider,
98+
bool suppressInputFormatterBuffering,
99+
bool suppressJsonDeserializationExceptionMessages)
73100
{
74101
if (logger == null)
75102
{
@@ -96,6 +123,7 @@ public JsonInputFormatter(
96123
_charPool = new JsonArrayPool<char>(charPool);
97124
_objectPoolProvider = objectPoolProvider;
98125
_suppressInputFormatterBuffering = suppressInputFormatterBuffering;
126+
_suppressJsonDeserializationExceptionMessages = suppressJsonDeserializationExceptionMessages;
99127

100128
SupportedEncodings.Add(UTF8EncodingWithoutBOM);
101129
SupportedEncodings.Add(UTF16EncodingLittleEndian);
@@ -187,7 +215,8 @@ void ErrorHandler(object sender, Newtonsoft.Json.Serialization.ErrorEventArgs ev
187215
}
188216

189217
var metadata = GetPathMetadata(context.Metadata, eventArgs.ErrorContext.Path);
190-
context.ModelState.TryAddModelError(key, eventArgs.ErrorContext.Error, metadata);
218+
var modelStateException = WrapExceptionForModelState(eventArgs.ErrorContext.Error);
219+
context.ModelState.TryAddModelError(key, modelStateException, metadata);
191220

192221
_logger.JsonInputException(eventArgs.ErrorContext.Error);
193222

@@ -315,5 +344,19 @@ private ModelMetadata GetPathMetadata(ModelMetadata metadata, string path)
315344

316345
return metadata;
317346
}
347+
348+
private Exception WrapExceptionForModelState(Exception exception)
349+
{
350+
// It's not known that Json.NET currently ever raises error events with exceptions
351+
// other than these two types, but we're being conservative and limiting which ones
352+
// we regard as having safe messages to expose to clients
353+
var isJsonExceptionType =
354+
exception is JsonReaderException || exception is JsonSerializationException;
355+
var suppressOriginalMessage =
356+
_suppressJsonDeserializationExceptionMessages || !isJsonExceptionType;
357+
return suppressOriginalMessage
358+
? exception
359+
: new InputFormatterException(exception.Message, exception);
360+
}
318361
}
319362
}

src/Microsoft.AspNetCore.Mvc.Formatters.Json/JsonPatchInputFormatter.cs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ public class JsonPatchInputFormatter : JsonInputFormatter
2727
/// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
2828
/// (<see cref="MvcJsonOptions.SerializerSettings"/>) or an instance
2929
/// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
30-
/// </param>/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
30+
/// </param>
31+
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
3132
/// <param name="objectPoolProvider">The <see cref="ObjectPoolProvider"/>.</param>
3233
public JsonPatchInputFormatter(
3334
ILogger logger,
@@ -46,7 +47,8 @@ public JsonPatchInputFormatter(
4647
/// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
4748
/// (<see cref="MvcJsonOptions.SerializerSettings"/>) or an instance
4849
/// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
49-
/// </param>/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
50+
/// </param>
51+
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
5052
/// <param name="objectPoolProvider">The <see cref="ObjectPoolProvider"/>.</param>
5153
/// <param name="suppressInputFormatterBuffering">Flag to buffer entire request body before deserializing it.</param>
5254
public JsonPatchInputFormatter(
@@ -55,7 +57,31 @@ public JsonPatchInputFormatter(
5557
ArrayPool<char> charPool,
5658
ObjectPoolProvider objectPoolProvider,
5759
bool suppressInputFormatterBuffering)
58-
: base(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering)
60+
: this(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering, suppressJsonDeserializationExceptionMessages: false)
61+
{
62+
}
63+
64+
/// <summary>
65+
/// Initializes a new <see cref="JsonPatchInputFormatter"/> instance.
66+
/// </summary>
67+
/// <param name="logger">The <see cref="ILogger"/>.</param>
68+
/// <param name="serializerSettings">
69+
/// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
70+
/// (<see cref="MvcJsonOptions.SerializerSettings"/>) or an instance
71+
/// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
72+
/// </param>
73+
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
74+
/// <param name="objectPoolProvider">The <see cref="ObjectPoolProvider"/>.</param>
75+
/// <param name="suppressInputFormatterBuffering">Flag to buffer entire request body before deserializing it.</param>
76+
/// <param name="suppressJsonDeserializationExceptionMessages">If <see langword="true"/>, JSON deserialization exception messages will replaced by a generic message in model state.</param>
77+
public JsonPatchInputFormatter(
78+
ILogger logger,
79+
JsonSerializerSettings serializerSettings,
80+
ArrayPool<char> charPool,
81+
ObjectPoolProvider objectPoolProvider,
82+
bool suppressInputFormatterBuffering,
83+
bool suppressJsonDeserializationExceptionMessages)
84+
: base(logger, serializerSettings, charPool, objectPoolProvider, suppressInputFormatterBuffering, suppressJsonDeserializationExceptionMessages)
5985
{
6086
// Clear all values and only include json-patch+json value.
6187
SupportedMediaTypes.Clear();

test/Microsoft.AspNetCore.Mvc.Abstractions.Test/ModelBinding/ModelStateDictionaryTest.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using Microsoft.AspNetCore.Mvc.Formatters;
56
using Microsoft.AspNetCore.Mvc.Internal;
67
using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;
78
using Microsoft.Extensions.Options;
@@ -1005,7 +1006,7 @@ public void ModelStateDictionary_AddsCustomErrorMessage_WhenModelStateSet_WithNo
10051006
}
10061007

10071008
[Fact]
1008-
public void ModelStateDictionary_NoErrorMessage_ForNonFormatException()
1009+
public void ModelStateDictionary_NoErrorMessage_ForUnrecognizedException()
10091010
{
10101011
// Arrange
10111012
var dictionary = new ModelStateDictionary();
@@ -1021,6 +1022,28 @@ public void ModelStateDictionary_NoErrorMessage_ForNonFormatException()
10211022
Assert.Empty(error.ErrorMessage);
10221023
}
10231024

1025+
[Fact]
1026+
public void ModelStateDictionary_AddsErrorMessage_ForInputFormatterException()
1027+
{
1028+
// Arrange
1029+
var expectedMessage = "This is an InputFormatterException";
1030+
var dictionary = new ModelStateDictionary();
1031+
1032+
var bindingMetadataProvider = new DefaultBindingMetadataProvider();
1033+
var compositeProvider = new DefaultCompositeMetadataDetailsProvider(new[] { bindingMetadataProvider });
1034+
var provider = new DefaultModelMetadataProvider(compositeProvider, new OptionsAccessor());
1035+
var metadata = provider.GetMetadataForType(typeof(int));
1036+
1037+
// Act
1038+
dictionary.TryAddModelError("key", new InputFormatterException(expectedMessage), metadata);
1039+
1040+
// Assert
1041+
var entry = Assert.Single(dictionary);
1042+
Assert.Equal("key", entry.Key);
1043+
var error = Assert.Single(entry.Value.Errors);
1044+
Assert.Equal(expectedMessage, error.ErrorMessage);
1045+
}
1046+
10241047
[Fact]
10251048
public void ModelStateDictionary_ClearEntriesThatMatchWithKey_NonEmptyKey()
10261049
{

test/Microsoft.AspNetCore.Mvc.Core.Test/ModelBinding/Binders/BodyModelBinderTests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,9 @@ public async Task BindModel_CustomFormatter_ThrowingInputFormatterException_Adds
232232
// Key is the empty string because this was a top-level binding.
233233
var entry = Assert.Single(bindingContext.ModelState);
234234
Assert.Equal(string.Empty, entry.Key);
235-
var errorMessage = Assert.Single(entry.Value.Errors).Exception.Message;
235+
var errorMessage = Assert.Single(entry.Value.Errors).ErrorMessage;
236236
Assert.Equal("Bad input!!", errorMessage);
237-
var formatException = Assert.IsType<FormatException>(entry.Value.Errors[0].Exception.InnerException);
238-
Assert.Same(expectedFormatException, formatException);
237+
Assert.Null(entry.Value.Errors[0].Exception);
239238
}
240239

241240
public static TheoryData<IInputFormatter, InputFormatterExceptionModelStatePolicy> BuiltInFormattersThrowingInputFormatterException
@@ -282,9 +281,9 @@ public async Task BindModel_BuiltInXmlInputFormatters_ThrowingInputFormatterExce
282281
// Key is the empty string because this was a top-level binding.
283282
var entry = Assert.Single(bindingContext.ModelState);
284283
Assert.Equal(string.Empty, entry.Key);
285-
var errorMessage = Assert.Single(entry.Value.Errors).Exception.Message;
284+
var errorMessage = Assert.Single(entry.Value.Errors).ErrorMessage;
286285
Assert.Equal("An error occured while deserializing input data.", errorMessage);
287-
Assert.IsType<InputFormatterException>(entry.Value.Errors[0].Exception);
286+
Assert.Null(entry.Value.Errors[0].Exception);
288287
}
289288

290289
[Theory]
@@ -319,7 +318,7 @@ public async Task BindModel_BuiltInJsonInputFormatter_ThrowingInputFormatterExce
319318
// Key is the empty string because this was a top-level binding.
320319
var entry = Assert.Single(bindingContext.ModelState);
321320
Assert.Equal(string.Empty, entry.Key);
322-
Assert.IsType<JsonReaderException>(entry.Value.Errors[0].Exception);
321+
Assert.NotEmpty(entry.Value.Errors[0].ErrorMessage);
323322
}
324323

325324
public static TheoryData<IInputFormatter, InputFormatterExceptionModelStatePolicy> DerivedFormattersThrowingInputFormatterException
@@ -366,9 +365,9 @@ public async Task BindModel_DerivedXmlInputFormatters_AddsErrorToModelState_(
366365
// Key is the empty string because this was a top-level binding.
367366
var entry = Assert.Single(bindingContext.ModelState);
368367
Assert.Equal(string.Empty, entry.Key);
369-
var errorMessage = Assert.Single(entry.Value.Errors).Exception.Message;
368+
var errorMessage = Assert.Single(entry.Value.Errors).ErrorMessage;
370369
Assert.Equal("An error occured while deserializing input data.", errorMessage);
371-
Assert.IsType<InputFormatterException>(entry.Value.Errors[0].Exception);
370+
Assert.Null(entry.Value.Errors[0].Exception);
372371
}
373372

374373
[Theory]
@@ -403,7 +402,8 @@ public async Task BindModel_DerivedJsonInputFormatter_AddsErrorToModelState(
403402
// Key is the empty string because this was a top-level binding.
404403
var entry = Assert.Single(bindingContext.ModelState);
405404
Assert.Equal(string.Empty, entry.Key);
406-
Assert.IsType<JsonReaderException>(entry.Value.Errors[0].Exception);
405+
Assert.NotEmpty(entry.Value.Errors[0].ErrorMessage);
406+
Assert.Null(entry.Value.Errors[0].Exception);
407407
}
408408

409409
// Throwing Non-InputFormatterException

test/Microsoft.AspNetCore.Mvc.Core.Test/ValidationProblemDetailsTest.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using System;
45
using Microsoft.AspNetCore.Mvc.ModelBinding;
56
using Xunit;
67

@@ -47,5 +48,30 @@ public void Constructor_SerializesErrorsFromModelStateDictionary()
4748
Assert.Equal(new[] { "error2", "error3" }, item.Value);
4849
});
4950
}
51+
52+
[Fact]
53+
public void Constructor_SerializesErrorsFromModelStateDictionary_AddsDefaultMessage()
54+
{
55+
// Arrange
56+
var modelStateDictionary = new ModelStateDictionary();
57+
var provider = new EmptyModelMetadataProvider();
58+
var metadata = provider.GetMetadataForProperty(typeof(string), nameof(string.Length));
59+
modelStateDictionary.AddModelError("unsafeError",
60+
new Exception("This message should not be returned to clients"),
61+
metadata);
62+
63+
// Act
64+
var problemDescription = new ValidationProblemDetails(modelStateDictionary);
65+
66+
// Assert
67+
Assert.Equal("One or more validation errors occured.", problemDescription.Title);
68+
Assert.Collection(
69+
problemDescription.Errors,
70+
item =>
71+
{
72+
Assert.Equal("unsafeError", item.Key);
73+
Assert.Equal(new[] { "The input was not valid." }, item.Value);
74+
});
75+
}
5076
}
5177
}

0 commit comments

Comments
 (0)