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

Make [FromBody] treat empty request bodies as invalid #6055

Merged
merged 1 commit into from
Apr 10, 2017
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 @@ -36,6 +36,36 @@ public InputFormatterContext(
ModelStateDictionary modelState,
ModelMetadata metadata,
Func<Stream, Encoding, TextReader> readerFactory)
: this(httpContext, modelName, modelState, metadata, readerFactory, treatEmptyInputAsDefaultValue: false)
{
}

/// <summary>
/// Creates a new instance of <see cref="InputFormatterContext"/>.
/// </summary>
/// <param name="httpContext">
/// The <see cref="Http.HttpContext"/> for the current operation.
/// </param>
/// <param name="modelName">The name of the model.</param>
/// <param name="modelState">
/// The <see cref="ModelStateDictionary"/> for recording errors.
/// </param>
/// <param name="metadata">
/// The <see cref="ModelMetadata"/> of the model to deserialize.
/// </param>
/// <param name="readerFactory">
/// A delegate which can create a <see cref="TextReader"/> for the request body.
/// </param>
/// <param name="treatEmptyInputAsDefaultValue">
/// A value for the <see cref="TreatEmptyInputAsDefaultValue"/> property.
/// </param>
public InputFormatterContext(
HttpContext httpContext,
string modelName,
ModelStateDictionary modelState,
ModelMetadata metadata,
Func<Stream, Encoding, TextReader> readerFactory,
bool treatEmptyInputAsDefaultValue)
{
if (httpContext == null)
{
Expand Down Expand Up @@ -67,9 +97,19 @@ public InputFormatterContext(
ModelState = modelState;
Metadata = metadata;
ReaderFactory = readerFactory;
TreatEmptyInputAsDefaultValue = treatEmptyInputAsDefaultValue;
ModelType = metadata.ModelType;
}

/// <summary>
/// Gets a flag to indicate whether the input formatter should allow no value to be provided.
/// If <see langword="false"/>, the input formatter should handle empty input by returning
/// <see cref="InputFormatterResult.NoValueAsync()"/>. If <see langword="true"/>, the input
/// formatter should handle empty input by returning the default value for the type
/// <see cref="ModelType"/>.
/// </summary>
public bool TreatEmptyInputAsDefaultValue { get; }

/// <summary>
/// Gets the <see cref="Http.HttpContext"/> associated with the current operation.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,32 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
/// </summary>
public class InputFormatterResult
{
private static readonly InputFormatterResult _failure = new InputFormatterResult();
private static readonly InputFormatterResult _failure = new InputFormatterResult(hasError: true);
private static readonly InputFormatterResult _noValue = new InputFormatterResult(hasError: false);
private static readonly Task<InputFormatterResult> _failureAsync = Task.FromResult(_failure);
private static readonly Task<InputFormatterResult> _noValueAsync = Task.FromResult(_noValue);

private InputFormatterResult()
private InputFormatterResult(bool hasError)
{
HasError = true;
HasError = hasError;
}

private InputFormatterResult(object model)
{
Model = model;
IsModelSet = true;
}

/// <summary>
/// Gets an indication whether the <see cref="IInputFormatter.ReadAsync"/> operation had an error.
/// </summary>
public bool HasError { get; }

/// <summary>
/// Gets an indication whether a value for the <see cref="Model"/> property was supplied.
/// </summary>
public bool IsModelSet { get; }

/// <summary>
/// Gets the deserialized <see cref="object"/>.
/// </summary>
Expand Down Expand Up @@ -89,5 +97,31 @@ public static Task<InputFormatterResult> SuccessAsync(object model)
{
return Task.FromResult(Success(model));
}

/// <summary>
/// Returns an <see cref="InputFormatterResult"/> indicating the <see cref="IInputFormatter.ReadAsync"/>
/// operation produced no value.
/// </summary>
/// <returns>
/// An <see cref="InputFormatterResult"/> indicating the <see cref="IInputFormatter.ReadAsync"/>
/// operation produced no value.
/// </returns>
public static InputFormatterResult NoValue()
{
return _noValue;
}

/// <summary>
/// Returns a <see cref="Task"/> that on completion provides an <see cref="InputFormatterResult"/> indicating
/// the <see cref="IInputFormatter.ReadAsync"/> operation produced no value.
/// </summary>
/// <returns>
/// A <see cref="Task"/> that on completion provides an <see cref="InputFormatterResult"/> indicating the
/// <see cref="IInputFormatter.ReadAsync"/> operation produced no value.
/// </returns>
public static Task<InputFormatterResult> NoValueAsync()
{
return _noValueAsync;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public interface IModelBindingMessageProvider
/// <value>Default <see cref="string"/> is "A value is required.".</value>
Func<string> MissingKeyOrValueAccessor { get; }

/// <summary>
/// Error message the model binding system adds when no value is provided for the request body,
/// but a value is required.
/// </summary>
/// <value>Default <see cref="string"/> is "A non-empty request body is required.".</value>
Func<string> MissingRequestBodyRequiredValueAccessor { get; }

/// <summary>
/// Error message the model binding system adds when a <c>null</c> value is bound to a
/// non-<see cref="Nullable"/> property.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
{
"OldTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewMemberId": "System.Func<System.String> get_MissingRequestBodyRequiredValueAccessor()",
"Kind": "Addition"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[
{
"OldTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewTypeId": "public interface Microsoft.AspNetCore.Mvc.ModelBinding.Metadata.IModelBindingMessageProvider",
"NewMemberId": "System.Func<System.String> get_MissingRequestBodyRequiredValueAccessor()",
"Kind": "Addition"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ public virtual Task<InputFormatterResult> ReadAsync(InputFormatterContext contex
var request = context.HttpContext.Request;
if (request.ContentLength == 0)
{
return InputFormatterResult.SuccessAsync(GetDefaultValueForType(context.ModelType));
if (context.TreatEmptyInputAsDefaultValue)
{
return InputFormatterResult.SuccessAsync(GetDefaultValueForType(context.ModelType));
}

return InputFormatterResult.NoValueAsync();
}

return ReadRequestBodyAsync(context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public void Configure(MvcOptions options)
// Set up ModelBinding
options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider());
options.ModelBinderProviders.Add(new ServicesModelBinderProvider());
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory));
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options));
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
options.ModelBinderProviders.Add(new SimpleTypeModelBinderProvider());
options.ModelBinderProviders.Add(new CancellationTokenModelBinderProvider());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class BodyModelBinder : IModelBinder
private readonly IList<IInputFormatter> _formatters;
private readonly Func<Stream, Encoding, TextReader> _readerFactory;
private readonly ILogger _logger;
private readonly MvcOptions _options;

/// <summary>
/// Creates a new <see cref="BodyModelBinder"/>.
Expand All @@ -45,7 +46,29 @@ public BodyModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamRead
/// instances for reading the request body.
/// </param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BodyModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory)
public BodyModelBinder(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory)
: this(formatters, readerFactory, loggerFactory, options: null)
{
}

/// <summary>
/// Creates a new <see cref="BodyModelBinder"/>.
/// </summary>
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
/// <param name="readerFactory">
/// The <see cref="IHttpRequestStreamReaderFactory"/>, used to create <see cref="System.IO.TextReader"/>
/// instances for reading the request body.
/// </param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="options">The <see cref="MvcOptions"/>.</param>
public BodyModelBinder(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory,
MvcOptions options)
{
if (formatters == null)
{
Expand All @@ -64,6 +87,8 @@ public BodyModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamRead
{
_logger = loggerFactory.CreateLogger<BodyModelBinder>();
}

_options = options;
}

/// <inheritdoc />
Expand All @@ -89,12 +114,15 @@ public async Task BindModelAsync(ModelBindingContext bindingContext)

var httpContext = bindingContext.HttpContext;

var allowEmptyInputInModelBinding = _options?.AllowEmptyInputInBodyModelBinding == true;

var formatterContext = new InputFormatterContext(
httpContext,
modelBindingKey,
bindingContext.ModelState,
bindingContext.ModelMetadata,
_readerFactory);
_readerFactory,
allowEmptyInputInModelBinding);

var formatter = (IInputFormatter)null;
for (var i = 0; i < _formatters.Count; i++)
Expand Down Expand Up @@ -132,7 +160,24 @@ public async Task BindModelAsync(ModelBindingContext bindingContext)
return;
}

bindingContext.Result = ModelBindingResult.Success(model);
if (result.IsModelSet)
{
bindingContext.Result = ModelBindingResult.Success(model);
}
else
{
// 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.
var message = bindingContext
.ModelMetadata
.ModelBindingMessageProvider
.MissingRequestBodyRequiredValueAccessor();
bindingContext.ModelState.AddModelError(modelBindingKey, message);
}

return;
}
catch (Exception ex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class BodyModelBinderProvider : IModelBinderProvider
private readonly IList<IInputFormatter> _formatters;
private readonly IHttpRequestStreamReaderFactory _readerFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly MvcOptions _options;

/// <summary>
/// Creates a new <see cref="BodyModelBinderProvider"/>.
Expand All @@ -36,6 +37,22 @@ public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestSt
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory, ILoggerFactory loggerFactory)
: this(formatters, readerFactory, loggerFactory, options: null)
{
}

/// <summary>
/// Creates a new <see cref="BodyModelBinderProvider"/>.
/// </summary>
/// <param name="formatters">The list of <see cref="IInputFormatter"/>.</param>
/// <param name="readerFactory">The <see cref="IHttpRequestStreamReaderFactory"/>.</param>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
/// <param name="options">The <see cref="MvcOptions"/>.</param>
public BodyModelBinderProvider(
IList<IInputFormatter> formatters,
IHttpRequestStreamReaderFactory readerFactory,
ILoggerFactory loggerFactory,
MvcOptions options)
{
if (formatters == null)
{
Expand All @@ -50,6 +67,7 @@ public BodyModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestSt
_formatters = formatters;
_readerFactory = readerFactory;
_loggerFactory = loggerFactory;
_options = options;
}

/// <inheritdoc />
Expand All @@ -71,7 +89,7 @@ public IModelBinder GetBinder(ModelBinderProviderContext context)
typeof(IInputFormatter).FullName));
}

return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory);
return new BodyModelBinder(_formatters, _readerFactory, _loggerFactory, _options);
}

return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public class ModelBindingMessageProvider : IModelBindingMessageProvider
{
private Func<string, string> _missingBindRequiredValueAccessor;
private Func<string> _missingKeyOrValueAccessor;
private Func<string> _missingRequestBodyRequiredValueAccessor;
private Func<string, string> _valueMustNotBeNullAccessor;
private Func<string, string, string> _attemptedValueIsInvalidAccessor;
private Func<string, string> _unknownValueIsInvalidAccessor;
Expand All @@ -26,6 +27,7 @@ public ModelBindingMessageProvider()
{
MissingBindRequiredValueAccessor = Resources.FormatModelBinding_MissingBindRequiredMember;
MissingKeyOrValueAccessor = Resources.FormatKeyValuePair_BothKeyAndValueMustBePresent;
MissingRequestBodyRequiredValueAccessor = Resources.FormatModelBinding_MissingRequestBodyRequiredMember;
ValueMustNotBeNullAccessor = Resources.FormatModelBinding_NullValueNotValid;
AttemptedValueIsInvalidAccessor = Resources.FormatModelState_AttemptedValueIsInvalid;
UnknownValueIsInvalidAccessor = Resources.FormatModelState_UnknownValueIsInvalid;
Expand All @@ -47,6 +49,7 @@ public ModelBindingMessageProvider(ModelBindingMessageProvider originalProvider)

MissingBindRequiredValueAccessor = originalProvider.MissingBindRequiredValueAccessor;
MissingKeyOrValueAccessor = originalProvider.MissingKeyOrValueAccessor;
MissingRequestBodyRequiredValueAccessor = originalProvider.MissingRequestBodyRequiredValueAccessor;
ValueMustNotBeNullAccessor = originalProvider.ValueMustNotBeNullAccessor;
AttemptedValueIsInvalidAccessor = originalProvider.AttemptedValueIsInvalidAccessor;
UnknownValueIsInvalidAccessor = originalProvider.UnknownValueIsInvalidAccessor;
Expand Down Expand Up @@ -90,6 +93,24 @@ public Func<string> MissingKeyOrValueAccessor
}
}

/// <inheritdoc/>
public Func<string> MissingRequestBodyRequiredValueAccessor
Copy link
Member

Choose a reason for hiding this comment

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

Look at existing tests that reference properties in this class. We test each to confirm changing them actually changes errors in the expected cases. In this case, it would be a check in the other InputFormatterTest class.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks - done.

{
get
{
return _missingRequestBodyRequiredValueAccessor;
}
set
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}

_missingRequestBodyRequiredValueAccessor = value;
}
}

/// <inheritdoc/>
public Func<string, string> ValueMustNotBeNullAccessor
{
Expand Down
12 changes: 12 additions & 0 deletions src/Microsoft.AspNetCore.Mvc.Core/MvcOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ public MvcOptions()
ValueProviderFactories = new List<IValueProviderFactory>();
}

/// <summary>
/// Gets or sets the flag which decides whether body model binding (for example, on an
/// action method parameter with <see cref="FromBodyAttribute"/>) should treat empty
/// input as valid. <see langword="false"/> by default.
/// </summary>
/// <example>
/// When <see langword="false"/>, actions that model bind the request body (for example,
/// using <see cref="FromBodyAttribute"/>) will register an error in the
/// <see cref="ModelStateDictionary"/> if the incoming request body is empty.
/// </example>
public bool AllowEmptyInputInBodyModelBinding { get; set; }

/// <summary>
/// Gets a Dictionary of CacheProfile Names, <see cref="CacheProfile"/> which are pre-defined settings for
/// response caching.
Expand Down
Loading