diff --git a/src/Exceptionless.Core/Services/EventPostBodyReadState.cs b/src/Exceptionless.Core/Services/EventPostBodyReadState.cs new file mode 100644 index 000000000..46b6a2bd1 --- /dev/null +++ b/src/Exceptionless.Core/Services/EventPostBodyReadState.cs @@ -0,0 +1,7 @@ +namespace Exceptionless.Core.Services; + +public interface IEventPostBodyReadState +{ + int? RejectedStatusCode { get; } + string? RejectionReason { get; } +} diff --git a/src/Exceptionless.Core/Services/EventPostEnqueueResult.cs b/src/Exceptionless.Core/Services/EventPostEnqueueResult.cs new file mode 100644 index 000000000..3a8cea35d --- /dev/null +++ b/src/Exceptionless.Core/Services/EventPostEnqueueResult.cs @@ -0,0 +1,20 @@ +namespace Exceptionless.Core.Services; + +public sealed record EventPostEnqueueResult(string? QueueEntryId = null, int? RejectedStatusCode = null, string? RejectionReason = null) +{ + public bool IsQueued => !String.IsNullOrEmpty(QueueEntryId); + public bool IsRejected => RejectedStatusCode.HasValue; + + public static EventPostEnqueueResult Queued(string queueEntryId) + { + ArgumentException.ThrowIfNullOrEmpty(queueEntryId); + return new EventPostEnqueueResult(queueEntryId); + } + + public static EventPostEnqueueResult Rejected(int statusCode, string? reason) + { + return new EventPostEnqueueResult(RejectedStatusCode: statusCode, RejectionReason: reason); + } + + public static EventPostEnqueueResult Failed { get; } = new(); +} diff --git a/src/Exceptionless.Core/Services/EventPostService.cs b/src/Exceptionless.Core/Services/EventPostService.cs index 38a047a3c..a02580898 100644 --- a/src/Exceptionless.Core/Services/EventPostService.cs +++ b/src/Exceptionless.Core/Services/EventPostService.cs @@ -22,6 +22,12 @@ public EventPostService(IQueue queue, IFileStorage storage, } public async Task EnqueueAsync(EventPost data, Stream stream, CancellationToken cancellationToken = default) + { + var result = await SaveAndEnqueueAsync(data, stream, cancellationToken); + return result.QueueEntryId; + } + + public async Task SaveAndEnqueueAsync(EventPost data, Stream stream, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(stream); @@ -38,24 +44,33 @@ public EventPostService(IQueue queue, IFileStorage storage, var saveTask = data.ShouldArchive ? _storage.SaveObjectAsync(data.FilePath, (EventPostInfo)data, cancellationToken) : Task.FromResult(true); var savePayloadTask = _storage.SaveFileAsync(Path.ChangeExtension(data.FilePath, ".payload"), stream, cancellationToken); - if (!await saveTask) + bool infoSaved = await saveTask; + bool payloadSaved = await savePayloadTask; + + if (stream is IEventPostBodyReadState { RejectedStatusCode: { } statusCode } rejectedBody) + { + await DeleteSavedEventPostFilesAsync(data); + return EventPostEnqueueResult.Rejected(statusCode, rejectedBody.RejectionReason); + } + + if (!infoSaved) { using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) _logger.LogError("Unable to save event post info"); - await savePayloadTask; - return null; + return EventPostEnqueueResult.Failed; } - if (!await savePayloadTask) + if (!payloadSaved) { using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) _logger.LogError("Unable to save event post payload"); - return null; + return EventPostEnqueueResult.Failed; } - return await _queue.EnqueueAsync(data); + string? queueEntryId = await _queue.EnqueueAsync(data); + return !String.IsNullOrEmpty(queueEntryId) ? EventPostEnqueueResult.Queued(queueEntryId) : EventPostEnqueueResult.Failed; } public async Task GetEventPostPayloadAsync(string path) @@ -109,4 +124,28 @@ private static string GetArchivePath(DateTime createdUtc, string projectId, stri { return Path.Combine("archive", createdUtc.ToString("yy"), createdUtc.ToString("MM"), createdUtc.ToString("dd"), createdUtc.ToString("HH"), createdUtc.ToString("mm"), projectId, fileName); } + + private async Task DeleteSavedEventPostFilesAsync(EventPost data) + { + if (String.IsNullOrEmpty(data.FilePath)) + return; + + try + { + var tasks = new List> + { + _storage.DeleteFileAsync(Path.ChangeExtension(data.FilePath, ".payload")) + }; + + if (data.ShouldArchive) + tasks.Add(_storage.DeleteFileAsync(data.FilePath)); + + await Task.WhenAll(tasks); + } + catch (StorageException ex) + { + using (_logger.BeginScope(new ExceptionlessState().Organization(data.OrganizationId).Property(nameof(EventPostInfo), data))) + _logger.LogWarning(ex, "Unable to delete rejected event post payload"); + } + } } diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index c3dba1bf1..d36e6c715 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -1195,6 +1195,7 @@ public Task LegacyPostAsync([FromHeader][UserAgent] string? userA [RequestBodyContentAttribute] [ConfigurationResponseFilter] [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)] public Task PostV1Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null) { return PostAsync(projectId, 1, userAgent); @@ -1253,6 +1254,7 @@ public Task PostV1Async(string? projectId = null, [FromHeader][Us [RequestBodyContentAttribute] [ConfigurationResponseFilter] [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)] public Task PostV2Async([FromHeader][UserAgent] string? userAgent = null) { return PostAsync(null, 2, userAgent); @@ -1312,6 +1314,7 @@ public Task PostV2Async([FromHeader][UserAgent] string? userAgent [RequestBodyContentAttribute] [ConfigurationResponseFilter] [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status413RequestEntityTooLarge)] public Task PostByProjectV2Async(string? projectId = null, [FromHeader][UserAgent] string? userAgent = null) { return PostAsync(projectId, 2, userAgent); @@ -1353,7 +1356,11 @@ private async Task PostAsync(string? projectId = null, int apiVer charSet = contentType.Charset.ToString(); } - await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive) + Stream requestBody = _appOptions.MaximumEventPostSize > 0 + ? new EventPostRequestBodyStream(Request.Body, _appOptions.MaximumEventPostSize) + : Request.Body; + + var result = await _eventPostService.SaveAndEnqueueAsync(new EventPost(_appOptions.EnableArchive) { ApiVersion = apiVersion, CharSet = charSet, @@ -1363,7 +1370,15 @@ await _eventPostService.EnqueueAsync(new EventPost(_appOptions.EnableArchive) OrganizationId = project.OrganizationId, ProjectId = project.Id, UserAgent = userAgent, - }, Request.Body); + }, requestBody, HttpContext.RequestAborted); + + if (result.IsRejected) + { + if (result.RejectedStatusCode == StatusCodes.Status413RequestEntityTooLarge) + await _usageService.IncrementTooBigAsync(project.OrganizationId, project.Id); + + return StatusCode(result.RejectedStatusCode.GetValueOrDefault(StatusCodes.Status400BadRequest)); + } } catch (Exception ex) { diff --git a/src/Exceptionless.Web/Program.cs b/src/Exceptionless.Web/Program.cs index db9dd7974..11a0fd0f5 100644 --- a/src/Exceptionless.Web/Program.cs +++ b/src/Exceptionless.Web/Program.cs @@ -3,6 +3,7 @@ using Exceptionless.Core.Configuration; using Exceptionless.Core.Extensions; using Exceptionless.Insulation.Configuration; +using Exceptionless.Web.Utility; using OpenTelemetry; using Serilog; using Serilog.Events; @@ -92,7 +93,7 @@ public static IHostBuilder CreateHostBuilder(IConfigurationRoot config, string e c.AddServerHeader = false; if (options.MaximumEventPostSize > 0) - c.Limits.MaxRequestBodySize = options.MaximumEventPostSize; + c.Limits.MaxRequestBodySize = options.MaximumEventPostSize + EventPostRequestBodyStream.KestrelBodyLimitSlopBytes; }) .UseStartup(); }) diff --git a/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs b/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs new file mode 100644 index 000000000..385f65c1d --- /dev/null +++ b/src/Exceptionless.Web/Utility/EventPostRequestBodyStream.cs @@ -0,0 +1,151 @@ +using Exceptionless.Core.Services; +using HttpBadHttpRequestException = Microsoft.AspNetCore.Http.BadHttpRequestException; + +namespace Exceptionless.Web.Utility; + +public sealed class EventPostRequestBodyStream : Stream, IEventPostBodyReadState +{ + public const long KestrelBodyLimitSlopBytes = 4096; + + private readonly Stream _inner; + private readonly long _maximumBytes; + private long _bytesRead; + + public EventPostRequestBodyStream(Stream inner, long maximumBytes) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumBytes); + + _inner = inner; + _maximumBytes = maximumBytes; + } + + public int? RejectedStatusCode { get; private set; } + public string? RejectionReason { get; private set; } + + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + _inner.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _inner.FlushAsync(cancellationToken); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + + if (count == 0 || RejectedStatusCode.HasValue) + return 0; + + int readLength = GetReadLength(count); + if (readLength == 0) + return 0; + + try + { + int bytesRead = _inner.Read(buffer, offset, readLength); + return HandleReadResult(bytesRead); + } + catch (HttpBadHttpRequestException ex) + { + Reject(ex.StatusCode, ex.Message); + return 0; + } + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (buffer.Length == 0 || RejectedStatusCode.HasValue) + return 0; + + int readLength = GetReadLength(buffer.Length); + if (readLength == 0) + return 0; + + try + { + int bytesRead = await _inner.ReadAsync(buffer[..readLength], cancellationToken); + return HandleReadResult(bytesRead); + } + catch (HttpBadHttpRequestException ex) + { + Reject(ex.StatusCode, ex.Message); + return 0; + } + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + private int GetReadLength(int requestedLength) + { + long remaining = _maximumBytes - _bytesRead; + if (remaining < 0) + { + Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large."); + return 0; + } + + if (remaining == 0) + return 1; + + if (remaining >= requestedLength) + return requestedLength; + + return (int)remaining + 1; + } + + private int HandleReadResult(int bytesRead) + { + if (bytesRead == 0) + return 0; + + long totalBytesRead = _bytesRead + bytesRead; + if (totalBytesRead > _maximumBytes) + { + Reject(StatusCodes.Status413RequestEntityTooLarge, "Request body too large."); + return 0; + } + + _bytesRead = totalBytesRead; + return bytesRead; + } + + private void Reject(int statusCode, string reason) + { + RejectedStatusCode ??= statusCode; + RejectionReason ??= reason; + } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 129e9a0c8..9f694cec1 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -2214,6 +2214,21 @@ "application/json": { } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "400": { "description": "No project id specified and no default project was found." }, @@ -2526,6 +2541,21 @@ "application/json": { } } }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "400": { "description": "No project id specified and no default project was found." }, @@ -4503,6 +4533,21 @@ "content": { "application/json": { } } + }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } }, "deprecated": true @@ -4554,6 +4599,21 @@ "content": { "application/json": { } } + }, + "413": { + "description": "Payload Too Large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } } }, "deprecated": true @@ -9507,6 +9567,42 @@ } } }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "status": { + "type": [ + "null", + "integer" + ], + "format": "int32" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "instance": { + "type": [ + "null", + "string" + ] + } + } + }, "ResetPasswordModel": { "required": [ "password_reset_token", diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 991fa6cd8..59311b617 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Web; +using Exceptionless.Core; using Exceptionless.Core.Billing; using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; @@ -26,6 +27,7 @@ using Foundatio.Repositories; using Foundatio.Repositories.Models; using Foundatio.Serializer; +using Foundatio.Storage; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Xunit; @@ -315,6 +317,30 @@ public async Task CanPostCompressedStringAsync() Assert.Equal(message, ev.Message); } + [Fact] + public async Task PostEvent_WithUnknownLengthPayloadOverLimit_ReturnsRequestEntityTooLargeAsync() + { + var options = GetService(); + byte[] payload = Encoding.UTF8.GetBytes(new string('x', (int)options.MaximumEventPostSize + 1)); + using var content = new UnknownLengthByteArrayContent(payload, "application/json"); + var client = CreateHttpClient(); + client.DefaultRequestHeaders.Add("Authorization", "Bearer " + TestConstants.ApiKey); + + var response = await client.PostAsync("events", content, TestCancellationToken); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + + var stats = await _eventQueue.GetQueueStatsAsync(); + Assert.Equal(0, stats.Enqueued); + + var usage = await GetService().GetUsageAsync(TestConstants.OrganizationId, TestConstants.ProjectId); + Assert.Equal(1, usage.CurrentUsage.TooBig); + Assert.Equal(1, usage.CurrentHourUsage.TooBig); + + var files = await GetService().GetFileListAsync(cancellationToken: TestCancellationToken); + Assert.Empty(files); + } + [Fact] public async Task CanPostJsonWithUserInfoAsync() { @@ -2194,4 +2220,31 @@ await SendRequestAsync(r => r Assert.NotNull(project); Assert.Equal(3, project.Usage.Sum(u => u.Deleted)); } + + private sealed class UnknownLengthByteArrayContent : HttpContent + { + private readonly byte[] _payload; + + public UnknownLengthByteArrayContent(byte[] payload, string mediaType) + { + _payload = payload; + Headers.ContentType = new MediaTypeHeaderValue(mediaType); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + return stream.WriteAsync(_payload, 0, _payload.Length); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + return stream.WriteAsync(_payload, cancellationToken).AsTask(); + } + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } } diff --git a/tests/Exceptionless.Tests/Services/EventPostServiceTests.cs b/tests/Exceptionless.Tests/Services/EventPostServiceTests.cs new file mode 100644 index 000000000..f661e5a8b --- /dev/null +++ b/tests/Exceptionless.Tests/Services/EventPostServiceTests.cs @@ -0,0 +1,53 @@ +using System.Text; +using Exceptionless.Core.Queues.Models; +using Exceptionless.Core.Services; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Utility; +using Foundatio.Queues; +using Foundatio.Storage; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class EventPostServiceTests : IntegrationTestsBase +{ + private readonly IQueue _eventQueue; + private readonly EventPostService _eventPostService; + private readonly IFileStorage _storage; + + public EventPostServiceTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _eventQueue = GetService>(); + _eventPostService = GetService(); + _storage = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + await _eventQueue.DeleteQueueAsync(); + } + + [Fact] + public async Task SaveAndEnqueueAsync_WhenBodyExceedsLimit_DoesNotQueueAndDeletesSavedFiles() + { + byte[] payload = Encoding.UTF8.GetBytes("123456"); + await using var stream = new EventPostRequestBodyStream(new MemoryStream(payload), 5); + + var result = await _eventPostService.SaveAndEnqueueAsync(new EventPost(true) + { + ApiVersion = 2, + MediaType = "application/json", + OrganizationId = TestConstants.OrganizationId, + ProjectId = TestConstants.ProjectId, + UserAgent = "exceptionless-test" + }, stream, TestCancellationToken); + + Assert.True(result.IsRejected); + Assert.Equal(StatusCodes.Status413RequestEntityTooLarge, result.RejectedStatusCode); + Assert.False(result.IsQueued); + Assert.Equal(0, (await _eventQueue.GetQueueStatsAsync()).Enqueued); + Assert.Empty(await _storage.GetFileListAsync(cancellationToken: TestCancellationToken)); + } +} diff --git a/tests/Exceptionless.Tests/Utility/EventPostRequestBodyStreamTests.cs b/tests/Exceptionless.Tests/Utility/EventPostRequestBodyStreamTests.cs new file mode 100644 index 000000000..881b09edb --- /dev/null +++ b/tests/Exceptionless.Tests/Utility/EventPostRequestBodyStreamTests.cs @@ -0,0 +1,35 @@ +using System.Text; +using Exceptionless.Web.Utility; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Exceptionless.Tests.Utility; + +public sealed class EventPostRequestBodyStreamTests +{ + [Fact] + public async Task ReadAsync_PayloadAtLimit_CompletesWithoutRejection() + { + byte[] payload = Encoding.UTF8.GetBytes("12345"); + await using var stream = new EventPostRequestBodyStream(new MemoryStream(payload), payload.Length); + await using var destination = new MemoryStream(); + + await stream.CopyToAsync(destination, 3, TestContext.Current.CancellationToken); + + Assert.Null(stream.RejectedStatusCode); + Assert.Equal(payload, destination.ToArray()); + } + + [Fact] + public async Task ReadAsync_PayloadOverLimit_EndsStreamAndMarksRejected() + { + byte[] payload = Encoding.UTF8.GetBytes("123456"); + await using var stream = new EventPostRequestBodyStream(new MemoryStream(payload), 5); + await using var destination = new MemoryStream(); + + await stream.CopyToAsync(destination, 3, TestContext.Current.CancellationToken); + + Assert.Equal(StatusCodes.Status413RequestEntityTooLarge, stream.RejectedStatusCode); + Assert.True(destination.Length < payload.Length); + } +}