diff --git a/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddleware.cs b/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddleware.cs new file mode 100644 index 00000000..ec471dd1 --- /dev/null +++ b/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddleware.cs @@ -0,0 +1,136 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog.Common; +using NLog.Web.LayoutRenderers; + +namespace NLog.Web +{ + /// + /// This class is to intercept the HTTP pipeline and to allow additional logging of the following + /// + /// POST request body + /// + /// Usage: app.UseMiddleware<NLogRequestPostBodyMiddleware>(); where app is an IApplicationBuilder + /// Register the NLogRequestPostBodyMiddlewareOption in the IoC so that the config gets passed to the constructor + /// + public class NLogRequestPostedBodyMiddleware + { + private readonly RequestDelegate _next; + + private readonly NLogRequestPostedBodyMiddlewareOptions _options; + + /// + /// Constructor that takes a configuration + /// + /// + /// + public NLogRequestPostedBodyMiddleware(RequestDelegate next, NLogRequestPostedBodyMiddlewareOptions options) + { + _next = next; + _options = options; + } + + /// + /// This allows interception of the HTTP pipeline for logging purposes + /// + /// The HttpContext + /// + public async Task Invoke(HttpContext context) + { + if (ShouldCaptureRequestBody(context)) + { + // This is required, otherwise reading the request will destructively read the request + context.Request.EnableBuffering(); + + // Save the POST request body in HttpContext.Items with a key of '__nlog-aspnet-request-posted-body' + var requestBody = await GetString(context.Request.Body).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(requestBody)) + { + context.Items[AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey] = requestBody; + } + } + + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + await _next(context).ConfigureAwait(false); + } + + private bool ShouldCaptureRequestBody(HttpContext context) + { + // Perform null checking + if (context == null) + { + InternalLogger.Debug("NLogRequestPostedBodyMiddleware: HttpContext is null"); + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + return false; + } + + if (context.Request == null) + { + InternalLogger.Debug("NLogRequestPostedBodyMiddleware: HttpContext.Request is null"); + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + return false; + } + + // Perform null checking + if (context.Request.Body == null) + { + InternalLogger.Debug("NLogRequestPostedBodyMiddleware: HttpContext.Request.Body stream is null"); + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + return false; + } + + // If we cannot read the stream we cannot capture the body + if (!context.Request.Body.CanRead) + { + InternalLogger.Debug("NLogRequestPostedBodyMiddleware: HttpContext.Request.Body stream is non-readable"); + // Execute the next class in the HTTP pipeline, this can be the next middleware or the actual handler + return false; + } + + return (_options.ShouldCapture(context)); + } + + /// + /// Convert the stream to a String for logging. + /// If the stream is binary please do not utilize this middleware + /// Arguably, logging a byte array in a sensible format is simply not possible. + /// + /// + /// The contents of the Stream read fully from start to end as a String + private async Task GetString(Stream stream) + { + // Save away the original stream position + var originalPosition = stream.Position; + + // This is required to reset the stream position to the beginning in order to properly read all of the stream. + stream.Position = 0; + + string responseText = null; + + // The last argument, leaveOpen, is set to true, so that the stream is not pre-maturely closed + // therefore preventing the next reader from reading the stream. + // The middle three arguments are from the configuration instance + // These default to UTF-8, true, and 1024. + using (var streamReader = new StreamReader( + stream, + Encoding.UTF8, + true, + 1024, + leaveOpen: true)) + { + // This is the most straight forward logic to read the entire body + responseText = await streamReader.ReadToEndAsync().ConfigureAwait(false); + } + + // This is required to reset the stream position to the original, in order to + // properly let the next reader process the stream from the original point + stream.Position = originalPosition; + + // Return the string of the body + return responseText; + } + } +} diff --git a/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddlewareOptions.cs b/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddlewareOptions.cs new file mode 100644 index 00000000..715000f2 --- /dev/null +++ b/src/NLog.Web.AspNetCore/NLogRequestPostedBodyMiddlewareOptions.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.AspNetCore.Http; + +namespace NLog.Web +{ + /// + /// Contains the configuration for the NLogRequestPostedBodyMiddleware + /// + public class NLogRequestPostedBodyMiddlewareOptions + { + /// + /// The default configuration + /// + internal static readonly NLogRequestPostedBodyMiddlewareOptions Default = new NLogRequestPostedBodyMiddlewareOptions(); + + /// + /// The default constructor + /// + public NLogRequestPostedBodyMiddlewareOptions() + { + ShouldCapture = DefaultCapture; + } + + /// + /// The maximum request size that will be captured + /// Defaults to 30KB. This checks against the ContentLength. + /// HttpRequest.EnableBuffer() writes the request to TEMP files on disk if the request ContentLength is > 30KB + /// but uses memory otherwise if <= 30KB, so we should protect against "very large" + /// request post body payloads. + /// + public int MaximumRequestSize { get; set; } = 30 * 1024; + + /// + /// If this returns true, the post request body will be captured + /// Defaults to true if content length <= 30KB + /// This can be used to capture only certain content types, + /// only certain hosts, only below a certain request body size, and so forth + /// + /// + public Predicate ShouldCapture { get; set; } + + /// + /// The default predicate for ShouldCapture + /// Returns true if content length <= 30KB + /// + private bool DefaultCapture(HttpContext context) + { + return context?.Request?.ContentLength != null && context?.Request?.ContentLength <= MaximumRequestSize; + } + } +} diff --git a/src/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRenderer.cs b/src/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRenderer.cs new file mode 100644 index 00000000..c0d8f9f1 --- /dev/null +++ b/src/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRenderer.cs @@ -0,0 +1,65 @@ +using System.Text; +using NLog.LayoutRenderers; +#if ASP_NET_CORE +using Microsoft.AspNetCore.Http; +#else +using System.Web; +#endif +namespace NLog.Web.LayoutRenderers +{ + /// + /// ASP.NET posted body, e.g. FORM or Ajax POST + /// + /// Example usage of ${aspnet-request-posted-body}: + /// + /// + /// ${aspnet-request-posted-body} - Produces - {username:xyz,password:xyz} + /// + /// + [LayoutRenderer("aspnet-request-posted-body")] + public class AspNetRequestPostedBodyLayoutRenderer : AspNetLayoutRendererBase + { + + /// + /// The object for the key in HttpContext.Items for the POST request body + /// + internal static readonly object NLogPostedRequestBodyKey = new object(); + + /// Renders the ASP.NET posted body + /// The to append the rendered data to. + /// Logging event. + protected override void DoAppend(StringBuilder builder, LogEventInfo logEvent) + { + var httpContext = HttpContextAccessor.HttpContext; + if (httpContext == null) + { + return; + } + + var items = httpContext.Items; + if (items == null) + { + return; + } + + if (httpContext.Items.Count == 0) + { + return; + } + +#if !ASP_NET_CORE + if (!items.Contains(NLogPostedRequestBodyKey)) + { + return; + } +#else + if (!items.ContainsKey(NLogPostedRequestBodyKey)) + { + return; + } +#endif + + builder.Append(items[NLogPostedRequestBodyKey] as string); + } + } +} diff --git a/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareOptionsTests.cs b/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareOptionsTests.cs new file mode 100644 index 00000000..ef6743ba --- /dev/null +++ b/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareOptionsTests.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace NLog.Web.Tests +{ + public class NLogRequestPostedBodyMiddlewareOptionsTests + { + [Fact] + public void SetMaximumRequestSizeTest() + { + var config = new NLogRequestPostedBodyMiddlewareOptions(); + var size = new Random().Next(); + config.MaximumRequestSize = size; + + Assert.Equal(size, config.MaximumRequestSize); + } + + [Fact] + public void GetDefault() + { + var config = NLogRequestPostedBodyMiddlewareOptions.Default; + + Assert.NotNull(config); + } + + [Fact] + public void DefaultCaptureTrue() + { + var config = NLogRequestPostedBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpRequest request = Substitute.For(); + + request.ContentLength.Returns(NLogRequestPostedBodyMiddlewareOptions.Default.MaximumRequestSize - 1); + + httpContext.Request.Returns(request); + + Assert.True(config.ShouldCapture(httpContext)); + } + + [Fact] + public void DefaultCaptureFalseNullContentLength() + { + var config = NLogRequestPostedBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpRequest request = Substitute.For(); + + request.ContentLength.Returns((long?)null); + + httpContext.Request.Returns(request); + + Assert.False(config.ShouldCapture(httpContext)); + } + + [Fact] + public void DefaultCaptureExcessiveContentLength() + { + var config = NLogRequestPostedBodyMiddlewareOptions.Default; + + HttpContext httpContext = Substitute.For(); + + HttpRequest request = Substitute.For(); + + request.ContentLength.Returns(NLogRequestPostedBodyMiddlewareOptions.Default.MaximumRequestSize + 1); + + httpContext.Request.Returns(request); + + Assert.False(config.ShouldCapture(httpContext)); + } + } +} diff --git a/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareTests.cs b/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareTests.cs new file mode 100644 index 00000000..aeeaa76c --- /dev/null +++ b/tests/NLog.Web.AspNetCore.Tests/NLogRequestPostedBodyMiddlewareTests.cs @@ -0,0 +1,195 @@ +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using NLog.Web.LayoutRenderers; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace NLog.Web.Tests +{ + public class NLogRequestPostedBodyMiddlewareTests + { + /// + /// This acts as a parameter for the RequestDelegate parameter for the middleware InvokeAsync method + /// + /// + /// + private static Task Next(HttpContext context) + { + return Task.CompletedTask; + } + + [Fact] + public void SuccessTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Request.Body = new MemoryStream(); + byte[] bodyBytes = Encoding.UTF8.GetBytes("This is a test request body"); + defaultContext.Request.Body.Write(bodyBytes,0,bodyBytes.Length); + defaultContext.Request.ContentLength = bodyBytes.Length; + + // Act + + long streamBeforePosition = defaultContext.Request.Body.Position; + + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next, NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + long streamAfterPosition = defaultContext.Request.Body.Position; + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Single(defaultContext.Items); + Assert.NotNull(defaultContext.Items[AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey]); + Assert.True(defaultContext.Items[AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey] is string); + Assert.Equal("This is a test request body", defaultContext.Items[AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey] as string); + Assert.Equal(streamBeforePosition, streamAfterPosition); + } + + [Fact] + public void EmptyBodyTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Request.Body = new MemoryStream(); + defaultContext.Request.ContentLength = 0; + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next, NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + Assert.Null(defaultContext.Items[AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey]); + } + + [Fact] + public void NullContextTest() + { + // Arrange + HttpContext defaultContext = null; + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next, NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + // Assert that we got to this point without NullReferenceException + Assert.True(true); + } + + [Fact] + public void NullRequestTest() + { + // Arrange + HttpContext defaultContext = Substitute.For(); + defaultContext.Request.ReturnsNull(); + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next, NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void NullBodyTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Request.Body = null; + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next,NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void ContentLengthTooLargeTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Request.Body = new MemoryStream(); + defaultContext.Request.Body.Write(new byte[30 * 1024 + 1],0, 30 * 1024 + 1); + defaultContext.Request.ContentLength = 30 * 1024 + 1; + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next,NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void MissingContentLengthTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + defaultContext.Request.Body = new MemoryStream(); + defaultContext.Request.Body.Write(new byte[128],0,128); + defaultContext.Request.ContentLength = null; + + // Act + var middlewareInstance = new NLogRequestPostedBodyMiddleware(Next,NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void CannotReadLengthTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + + defaultContext.Request.Body = Substitute.For(); + + defaultContext.Request.Body.CanRead.Returns(false); + defaultContext.Request.Body.CanSeek.Returns(true); + + // Act + var middlewareInstance = + new NLogRequestPostedBodyMiddleware(Next,NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + + [Fact] + public void CannotSeekLengthTest() + { + // Arrange + DefaultHttpContext defaultContext = new DefaultHttpContext(); + + defaultContext.Request.Body = Substitute.For(); + + defaultContext.Request.Body.CanRead.Returns(true); + defaultContext.Request.Body.CanSeek.Returns(false); + + // Act + var middlewareInstance = + new NLogRequestPostedBodyMiddleware(Next,NLogRequestPostedBodyMiddlewareOptions.Default); + middlewareInstance.Invoke(defaultContext).ConfigureAwait(false).GetAwaiter().GetResult(); + + // Assert + Assert.NotNull(defaultContext.Items); + Assert.Empty(defaultContext.Items); + } + } +} diff --git a/tests/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRendererTests.cs b/tests/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRendererTests.cs new file mode 100644 index 00000000..9b774c63 --- /dev/null +++ b/tests/Shared/LayoutRenderers/AspNetRequestPostedBodyLayoutRendererTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +#if ASP_NET_CORE +using Microsoft.AspNetCore.Http; +#else +using System.Web; +#endif +using NLog.Web.LayoutRenderers; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace NLog.Web.Tests.LayoutRenderers +{ + public class AspNetRequestPostedBodyLayoutRendererTests : LayoutRenderersTestBase + { + [Fact] + public void RequestPostedBodyPresentRenderNonEmptyString() + { + // Arrange + var (renderer, httpContext) = CreateWithHttpContext(); + string expected = "This is a test of the request posted body layout renderer."; + var items = new Dictionary(); + items.Add(AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey, expected); + httpContext.Items.Returns(items); + // Act + var result = renderer.Render(new LogEventInfo()); + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void NullItemsRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.ReturnsNull(); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void EmptyItemsRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary()); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NonEmptyItemsWithoutPostedBodyRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary + { + {AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey + "X","Not the Posted Body Value"} + }); + + string result = renderer.Render(new LogEventInfo()); + + Assert.NotEmpty(httpContext.Items); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NotStringTypeRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + httpContext.Items.Returns(new Dictionary + { + {AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey, 42} + }); + + string result = renderer.Render(new LogEventInfo()); + + Assert.Equal(string.Empty, result); + } + + [Fact] + public void NullHttpContextRendersEmptyString() + { + var (renderer, httpContext) = CreateWithHttpContext(); + + renderer.HttpContextAccessor = Substitute.For(); + renderer.HttpContextAccessor.HttpContext.ReturnsNull(); + + string expected = "This is a test of the request posted body layout renderer."; + var items = new Dictionary {{AspNetRequestPostedBodyLayoutRenderer.NLogPostedRequestBodyKey, expected}}; + httpContext.Items.Returns(items); + + // Act + var result = renderer.Render(new LogEventInfo()); + + // Assert + Assert.Equal(string.Empty, result); + } + } +}