diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index afed27b0e2e0e06a2bca47231e588a98482ac016..657b7d66d1144930af5a8018cbcc555f15661224 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -558,6 +558,12 @@ namespace Microsoft.AspNetCore.Http var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>(); if (feature?.CanHaveBody == true) { + if (!httpContext.Request.HasJsonContentType()) + { + Log.UnexpectedContentType(httpContext, httpContext.Request.ContentType, factoryContext.ThrowOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return; + } try { bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType); @@ -590,6 +596,12 @@ namespace Microsoft.AspNetCore.Http var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>(); if (feature?.CanHaveBody == true) { + if (!httpContext.Request.HasJsonContentType()) + { + Log.UnexpectedContentType(httpContext, httpContext.Request.ContentType, factoryContext.ThrowOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return; + } try { bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType); @@ -603,7 +615,7 @@ namespace Microsoft.AspNetCore.Http { Log.RequestBodyInvalidDataException(httpContext, ex, factoryContext.ThrowOnBadRequest); - httpContext.Response.StatusCode = 400; + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; return; } } @@ -1155,6 +1167,9 @@ namespace Microsoft.AspNetCore.Http private const string RequiredParameterNotProvidedLogMessage = @"Required parameter ""{ParameterType} {ParameterName}"" was not provided from {Source}."; private const string RequiredParameterNotProvidedExceptionMessage = @"Required parameter ""{0} {1}"" was not provided from {2}."; + private const string UnexpectedContentTypeLogMessage = @"Expected a supported JSON media type but got ""{ContentType}""."; + private const string UnexpectedContentTypeExceptionMessage = @"Expected a supported JSON media type but got ""{0}""."; + // This doesn't take a shouldThrow parameter because an IOException indicates an aborted request rather than a "bad" request so // a BadHttpRequestException feels wrong. The client shouldn't be able to read the Developer Exception Page at any rate. public static void RequestBodyIOException(HttpContext httpContext, IOException exception) @@ -1204,6 +1219,20 @@ namespace Microsoft.AspNetCore.Http [LoggerMessage(4, LogLevel.Debug, RequiredParameterNotProvidedLogMessage, EventName = "RequiredParameterNotProvided")] private static partial void RequiredParameterNotProvided(ILogger logger, string parameterType, string parameterName, string source); + public static void UnexpectedContentType(HttpContext httpContext, string? contentType, bool shouldThrow) + { + if (shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, UnexpectedContentTypeExceptionMessage, contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + UnexpectedContentType(GetLogger(httpContext), contentType ?? "(none)"); + } + + [LoggerMessage(6, LogLevel.Debug, UnexpectedContentTypeLogMessage, EventName = "UnexpectedContentType")] + private static partial void UnexpectedContentType(ILogger logger, string contentType); + private static ILogger GetLogger(HttpContext httpContext) { var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>(); diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 16c844ec762ed5a8361c94952ff1354ca4d58ff0..21e4d409dc72f83ddd32e9028ebd4b8b459f9877 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -2360,7 +2360,93 @@ namespace Microsoft.AspNetCore.Routing.Internal Assert.False(httpContext.RequestAborted.IsCancellationRequested); var decodedResponseBody = Encoding.UTF8.GetString(responseBodyStream.ToArray()); Assert.Equal(@"""Hello Tester. This is from an extension method.""", decodedResponseBody); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateRejectsNonJsonContent(bool shouldThrow) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/xml"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true)); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(LoggerFactory); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var factoryResult = RequestDelegateFactory.Create((HttpContext context, Todo todo) => + { + }, new RequestDelegateFactoryOptions() { ThrowOnBadRequest = shouldThrow }); + var requestDelegate = factoryResult.RequestDelegate; + var request = requestDelegate(httpContext); + + if (shouldThrow) + { + var ex = await Assert.ThrowsAsync<BadHttpRequestException>(() => request); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); + } + else + { + await request; + + Assert.Equal(415, httpContext.Response.StatusCode); + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateWithBindAndImplicitBodyRejectsNonJsonContent(bool shouldThrow) + { + Todo originalTodo = new() + { + Name = "Write more tests!" + }; + + var httpContext = new DefaultHttpContext(); + + var requestBodyBytes = JsonSerializer.SerializeToUtf8Bytes(originalTodo); + var stream = new MemoryStream(requestBodyBytes); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "application/xml"; + httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture); + httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RequestBodyDetectionFeature(true)); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(LoggerFactory); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var factoryResult = RequestDelegateFactory.Create((HttpContext context, JsonTodo customTodo, Todo todo) => + { + }, new RequestDelegateFactoryOptions() { ThrowOnBadRequest = shouldThrow }); + var requestDelegate = factoryResult.RequestDelegate; + + var request = requestDelegate(httpContext); + + if (shouldThrow) + { + var ex = await Assert.ThrowsAsync<BadHttpRequestException>(() => request); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", ex.Message); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); + } + else + { + await request; + + Assert.Equal(415, httpContext.Response.StatusCode); + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(6, "UnexpectedContentType"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Expected a supported JSON media type but got \"application/xml\".", logMessage.Message); + } } private DefaultHttpContext CreateHttpContext() @@ -2399,6 +2485,17 @@ namespace Microsoft.AspNetCore.Routing.Internal } } + private class JsonTodo : Todo + { + public static async ValueTask<JsonTodo?> BindAsync(HttpContext context, ParameterInfo parameter) + { + // manually call deserialize so we don't check content type + var body = await JsonSerializer.DeserializeAsync<JsonTodo>(context.Request.Body); + context.Request.Body.Position = 0; + return body; + } + } + private record struct TodoStruct(int Id, string? Name, bool IsComplete) : ITodo; private interface ITodo