From c211c249e1d24cc2aadba315fe3a7ba7891d9646 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 16 May 2026 14:40:01 +0300 Subject: [PATCH 1/3] feat: improve request/response capture and compare safety --- .../{Resources => Assets}/css/debugprobe.css | 4 + .../html/_shared/layout.html | 0 .../{Resources => Assets}/html/details.html | 0 .../{Resources => Assets}/html/index.html | 0 .../{ => images}/debugprobe_favicon.ico | Bin .../debugprobe_logo_white_transparent.png | Bin .../js/debugprobe-compare-engine.js} | 0 .../js/debugprobe-compare-renderer.js} | 6 +- .../js/debugprobe-ui.js} | 6 + .../DebugProbe.AspNetCore.csproj | 15 +- .../Extensions/DebugProbeExtensions.cs | 49 +++--- .../Internal/Compare/CompareUrlValidator.cs | 102 ++++++++++++ .../{ => Compare}/DebugEntryComparer.cs | 2 +- .../Internal/{ => Rendering}/HtmlRenderer.cs | 8 +- .../Internal/Resources/EmbeddedAssetWriter.cs | 23 +++ .../{ => Resources}/EmbeddedResources.cs | 14 +- .../{ => Resources}/ResourceLoader.cs | 4 +- .../Streams/BoundedResponseCaptureStream.cs | 97 +++++++++++ .../Internal/{ => Utils}/EnvironmentUtils.cs | 2 +- .../Internal/{ => Utils}/JsonUtils.cs | 2 +- .../Middleware/DebugProbeMiddleware.cs | 152 +++++++++++++++--- DebugProbe.AspNetCore/Models/DebugEntry.cs | 4 - .../Options/DebugProbeOptions.cs | 7 +- .../Storage/DebugEntryStore.cs | 2 +- DebugProbe.SampleApi/Program.cs | 1 + 25 files changed, 423 insertions(+), 77 deletions(-) rename DebugProbe.AspNetCore/{Resources => Assets}/css/debugprobe.css (99%) rename DebugProbe.AspNetCore/{Resources => Assets}/html/_shared/layout.html (100%) rename DebugProbe.AspNetCore/{Resources => Assets}/html/details.html (100%) rename DebugProbe.AspNetCore/{Resources => Assets}/html/index.html (100%) rename DebugProbe.AspNetCore/Assets/{ => images}/debugprobe_favicon.ico (100%) rename DebugProbe.AspNetCore/Assets/{ => images}/debugprobe_logo_white_transparent.png (100%) rename DebugProbe.AspNetCore/{Resources/js/debugprobe_compare_engine.js => Assets/js/debugprobe-compare-engine.js} (100%) rename DebugProbe.AspNetCore/{Resources/js/debugprobe_compare_renderer.js => Assets/js/debugprobe-compare-renderer.js} (97%) rename DebugProbe.AspNetCore/{Resources/js/debugprobe_ui.js => Assets/js/debugprobe-ui.js} (75%) create mode 100644 DebugProbe.AspNetCore/Internal/Compare/CompareUrlValidator.cs rename DebugProbe.AspNetCore/Internal/{ => Compare}/DebugEntryComparer.cs (98%) rename DebugProbe.AspNetCore/Internal/{ => Rendering}/HtmlRenderer.cs (94%) create mode 100644 DebugProbe.AspNetCore/Internal/Resources/EmbeddedAssetWriter.cs rename DebugProbe.AspNetCore/Internal/{ => Resources}/EmbeddedResources.cs (64%) rename DebugProbe.AspNetCore/Internal/{ => Resources}/ResourceLoader.cs (87%) create mode 100644 DebugProbe.AspNetCore/Internal/Streams/BoundedResponseCaptureStream.cs rename DebugProbe.AspNetCore/Internal/{ => Utils}/EnvironmentUtils.cs (84%) rename DebugProbe.AspNetCore/Internal/{ => Utils}/JsonUtils.cs (95%) diff --git a/DebugProbe.AspNetCore/Resources/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css similarity index 99% rename from DebugProbe.AspNetCore/Resources/css/debugprobe.css rename to DebugProbe.AspNetCore/Assets/css/debugprobe.css index aef0974..12a2c85 100644 --- a/DebugProbe.AspNetCore/Resources/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -151,6 +151,10 @@ tr:hover { background: #f1f1f1; } +.clickable-row { + cursor: pointer; +} + /* ========================= Section Titles ========================= */ diff --git a/DebugProbe.AspNetCore/Resources/html/_shared/layout.html b/DebugProbe.AspNetCore/Assets/html/_shared/layout.html similarity index 100% rename from DebugProbe.AspNetCore/Resources/html/_shared/layout.html rename to DebugProbe.AspNetCore/Assets/html/_shared/layout.html diff --git a/DebugProbe.AspNetCore/Resources/html/details.html b/DebugProbe.AspNetCore/Assets/html/details.html similarity index 100% rename from DebugProbe.AspNetCore/Resources/html/details.html rename to DebugProbe.AspNetCore/Assets/html/details.html diff --git a/DebugProbe.AspNetCore/Resources/html/index.html b/DebugProbe.AspNetCore/Assets/html/index.html similarity index 100% rename from DebugProbe.AspNetCore/Resources/html/index.html rename to DebugProbe.AspNetCore/Assets/html/index.html diff --git a/DebugProbe.AspNetCore/Assets/debugprobe_favicon.ico b/DebugProbe.AspNetCore/Assets/images/debugprobe_favicon.ico similarity index 100% rename from DebugProbe.AspNetCore/Assets/debugprobe_favicon.ico rename to DebugProbe.AspNetCore/Assets/images/debugprobe_favicon.ico diff --git a/DebugProbe.AspNetCore/Assets/debugprobe_logo_white_transparent.png b/DebugProbe.AspNetCore/Assets/images/debugprobe_logo_white_transparent.png similarity index 100% rename from DebugProbe.AspNetCore/Assets/debugprobe_logo_white_transparent.png rename to DebugProbe.AspNetCore/Assets/images/debugprobe_logo_white_transparent.png diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_engine.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-engine.js similarity index 100% rename from DebugProbe.AspNetCore/Resources/js/debugprobe_compare_engine.js rename to DebugProbe.AspNetCore/Assets/js/debugprobe-compare-engine.js diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js similarity index 97% rename from DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js rename to DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js index 25be611..b822ea2 100644 --- a/DebugProbe.AspNetCore/Resources/js/debugprobe_compare_renderer.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-compare-renderer.js @@ -24,7 +24,7 @@ const text = await res.text(); - setCompareResult(`${text || 'Compare failed'}`); + setCompareResult(`${escapeHtml(text || 'Compare failed')}`); return; } @@ -33,10 +33,10 @@ setCompareResult(renderCompare(result)); - } catch { + } catch (error) { setCompareResult( - `${error}` + `${escapeHtml(error.message || 'Compare failed')}` ); } }; diff --git a/DebugProbe.AspNetCore/Resources/js/debugprobe_ui.js b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js similarity index 75% rename from DebugProbe.AspNetCore/Resources/js/debugprobe_ui.js rename to DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js index 8eab5ce..d8cff00 100644 --- a/DebugProbe.AspNetCore/Resources/js/debugprobe_ui.js +++ b/DebugProbe.AspNetCore/Assets/js/debugprobe-ui.js @@ -20,3 +20,9 @@ if (clearBtn) { location.reload(); }); } + +document.querySelectorAll(".clickable-row[data-url]").forEach(row => { + row.addEventListener("click", () => { + window.location.assign(row.dataset.url); + }); +}); diff --git a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj index fcc5b65..744b444 100644 --- a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj +++ b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj @@ -34,17 +34,15 @@ - - - - - + + + + + - - - + @@ -57,4 +55,5 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 8ca3f6d..17595dd 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -1,5 +1,8 @@ using System.Net.Http.Json; -using DebugProbe.AspNetCore.Internal; +using DebugProbe.AspNetCore.Internal.Compare; +using DebugProbe.AspNetCore.Internal.Rendering; +using DebugProbe.AspNetCore.Internal.Resources; +using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Middleware; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; @@ -12,7 +15,10 @@ namespace DebugProbe.AspNetCore.Extensions; public static class DebugProbeExtensions { - private static readonly HttpClient Http = new(); + private static readonly HttpClient Http = new() + { + Timeout = TimeSpan.FromSeconds(5) + }; public static IServiceCollection AddDebugProbe( this IServiceCollection services, @@ -66,7 +72,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) await ctx.Response.WriteAsync(html); }).ExcludeFromDescription(); - webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId, DebugEntryStore store) => + webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId, + DebugEntryStore store, + DebugProbeOptions options) => { var localEnvironment = store.Environment; var localEntry = store.Get(id); @@ -75,14 +83,23 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) return Results.NotFound("Local trace not found"); } + if (!Guid.TryParse(remoteTraceId, out _)) + { + return Results.BadRequest("Invalid remote trace id"); + } - var normalizedBaseUrl = baseUrl.TrimEnd('/'); + var validation = await CompareUrlValidator.ValidateCompareBaseUrlAsync(baseUrl, options); + + if (!validation.IsValid) + { + return Results.BadRequest(validation.Error); + } var remoteEnvironmentUrl = - $"{normalizedBaseUrl}/debug/environment"; + new Uri(validation.BaseUri!, "/debug/environment"); var remoteEntryUrl = - $"{normalizedBaseUrl}/debug/json/{remoteTraceId}"; + new Uri(validation.BaseUri!, $"/debug/json/{remoteTraceId}"); DebugEntry? remoteEntry; DebugEnvironment? remoteEnvironment; @@ -161,30 +178,16 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) }).ExcludeFromDescription(); webApp.Map("/debug/logo.png", ctx => - WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.debugprobe_logo_white_transparent.png", "image/png") + EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png") ).ExcludeFromDescription(); webApp.Map("/debug/favicon.ico", ctx => - WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.debugprobe_favicon.ico", "image/x-icon") + EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon") ).ExcludeFromDescription(); } return app; } - private static async Task WriteEmbeddedAsset(HttpContext ctx, string resourceName, string contentType) - { - ctx.Response.ContentType = contentType; - - var assembly = typeof(DebugProbeMiddleware).Assembly; - using var stream = assembly.GetManifestResourceStream(resourceName); - - if (stream is null) - { - ctx.Response.StatusCode = 404; - return; - } - - await stream.CopyToAsync(ctx.Response.Body); - } + } diff --git a/DebugProbe.AspNetCore/Internal/Compare/CompareUrlValidator.cs b/DebugProbe.AspNetCore/Internal/Compare/CompareUrlValidator.cs new file mode 100644 index 0000000..d01b2c4 --- /dev/null +++ b/DebugProbe.AspNetCore/Internal/Compare/CompareUrlValidator.cs @@ -0,0 +1,102 @@ +using DebugProbe.AspNetCore.Options; + +namespace DebugProbe.AspNetCore.Internal.Compare; + +internal static class CompareUrlValidator +{ + public static async Task<(bool IsValid, Uri? BaseUri, string Error)> ValidateCompareBaseUrlAsync(string baseUrl, DebugProbeOptions options) + { + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var parsed)) + { + return (false, null, "Invalid compare server URL"); + } + + if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) + { + return (false, null, "Compare server URL must use http or https"); + } + + if (!string.IsNullOrEmpty(parsed.UserInfo)) + { + return (false, null, "Compare server URL cannot include credentials"); + } + + System.Net.IPAddress[] addresses; + + try + { + addresses = System.Net.IPAddress.TryParse(parsed.Host, out var ipAddress) + ? [ipAddress] + : await System.Net.Dns.GetHostAddressesAsync(parsed.DnsSafeHost); + } + catch + { + return (false, null, "Failed to resolve compare server host"); + } + + if (!options.AllowLocalCompareTargets) + { + if (IsLocalHostName(parsed.Host)) + { + return (false, null, "Compare server URL cannot target localhost"); + } + + if (addresses.Length == 0 || addresses.Any(IsPrivateOrLocalAddress)) + { + return (false, null, "Compare server URL cannot target local or private network addresses"); + } + } + + return (true, new Uri(parsed.GetLeftPart(UriPartial.Authority)), string.Empty); + } + + private static bool IsLocalHostName(string host) + { + return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, Environment.MachineName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPrivateOrLocalAddress(System.Net.IPAddress address) + { + if (System.Net.IPAddress.IsLoopback(address) || + System.Net.IPAddress.Any.Equals(address) || + System.Net.IPAddress.None.Equals(address) || + System.Net.IPAddress.Broadcast.Equals(address)) + { + return true; + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + var bytes = address.GetAddressBytes(); + + return bytes[0] == 10 || + bytes[0] == 127 || + bytes[0] == 0 || + bytes[0] == 169 && bytes[1] == 254 || + bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31 || + bytes[0] == 192 && bytes[1] == 168 || + bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127 || + bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19); + } + + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) + { + var bytes = address.GetAddressBytes(); + + return address.IsIPv6LinkLocal || + address.IsIPv6SiteLocal || + address.IsIPv6Multicast || + System.Net.IPAddress.IPv6Any.Equals(address) || + System.Net.IPAddress.IPv6None.Equals(address) || + bytes[0] is >= 0xfc and <= 0xfd; + } + + return true; + } +} diff --git a/DebugProbe.AspNetCore/Internal/DebugEntryComparer.cs b/DebugProbe.AspNetCore/Internal/Compare/DebugEntryComparer.cs similarity index 98% rename from DebugProbe.AspNetCore/Internal/DebugEntryComparer.cs rename to DebugProbe.AspNetCore/Internal/Compare/DebugEntryComparer.cs index 2149471..5b9c0ce 100644 --- a/DebugProbe.AspNetCore/Internal/DebugEntryComparer.cs +++ b/DebugProbe.AspNetCore/Internal/Compare/DebugEntryComparer.cs @@ -1,7 +1,7 @@ using System.Text.Json; using DebugProbe.AspNetCore.Models; -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Compare; /// /// Compares two DebugEntry instances and produces a list of differences, diff --git a/DebugProbe.AspNetCore/Internal/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs similarity index 94% rename from DebugProbe.AspNetCore/Internal/HtmlRenderer.cs rename to DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index 6f847a0..fe55761 100644 --- a/DebugProbe.AspNetCore/Internal/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -1,7 +1,9 @@ using System.Net; +using DebugProbe.AspNetCore.Internal.Resources; +using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Rendering; /// /// Renders DebugProbe UI pages (layout, index, details) using embedded HTML templates. @@ -23,7 +25,7 @@ public static string BuildLayout(string content) public static string RenderIndexPage(List items) { var rows = string.Join("", items.Select(x => $@" - + {x.Timestamp:HH:mm:ss} {Encode(x.Method)} {Encode(x.Path)} @@ -95,7 +97,7 @@ private static string Encode(string? value) private static string GetStatusText(int statusCode) { - return $"{statusCode} {((HttpStatusCode)statusCode)}"; + return $"{statusCode} {(HttpStatusCode)statusCode}"; } private static string GetStatusClass(int statusCode) diff --git a/DebugProbe.AspNetCore/Internal/Resources/EmbeddedAssetWriter.cs b/DebugProbe.AspNetCore/Internal/Resources/EmbeddedAssetWriter.cs new file mode 100644 index 0000000..36f558f --- /dev/null +++ b/DebugProbe.AspNetCore/Internal/Resources/EmbeddedAssetWriter.cs @@ -0,0 +1,23 @@ +using DebugProbe.AspNetCore.Middleware; +using Microsoft.AspNetCore.Http; + +namespace DebugProbe.AspNetCore.Internal.Resources; + +internal static class EmbeddedAssetWriter +{ + public static async Task WriteEmbeddedAsset(HttpContext ctx, string resourceName, string contentType) + { + ctx.Response.ContentType = contentType; + + var assembly = typeof(DebugProbeMiddleware).Assembly; + using var stream = assembly.GetManifestResourceStream(resourceName); + + if (stream is null) + { + ctx.Response.StatusCode = 404; + return; + } + + await stream.CopyToAsync(ctx.Response.Body); + } +} diff --git a/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs b/DebugProbe.AspNetCore/Internal/Resources/EmbeddedResources.cs similarity index 64% rename from DebugProbe.AspNetCore/Internal/EmbeddedResources.cs rename to DebugProbe.AspNetCore/Internal/Resources/EmbeddedResources.cs index ffa9262..a301d82 100644 --- a/DebugProbe.AspNetCore/Internal/EmbeddedResources.cs +++ b/DebugProbe.AspNetCore/Internal/Resources/EmbeddedResources.cs @@ -1,4 +1,4 @@ -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Resources; /// /// Provides access to embedded UI resources (HTML, CSS, JS) used by DebugProbe. @@ -16,11 +16,13 @@ internal static class EmbeddedResources public static readonly Dictionary JavaScript = new() { - ["debugprobe-compare-renderer.js"] = ResourceLoader.LoadJs("debugprobe_compare_renderer.js"), + ["debugprobe-compare-renderer.js"] = + ResourceLoader.LoadJs("debugprobe-compare-renderer.js"), - ["debugprobe-compare-engine.js"] = ResourceLoader.LoadJs("debugprobe_compare_engine.js"), + ["debugprobe-compare-engine.js"] = + ResourceLoader.LoadJs("debugprobe-compare-engine.js"), - ["debugprobe-ui.js"] = ResourceLoader.LoadJs("debugprobe_ui.js") + ["debugprobe-ui.js"] = + ResourceLoader.LoadJs("debugprobe-ui.js") }; -} - +} \ No newline at end of file diff --git a/DebugProbe.AspNetCore/Internal/ResourceLoader.cs b/DebugProbe.AspNetCore/Internal/Resources/ResourceLoader.cs similarity index 87% rename from DebugProbe.AspNetCore/Internal/ResourceLoader.cs rename to DebugProbe.AspNetCore/Internal/Resources/ResourceLoader.cs index fa1f784..c0cf33e 100644 --- a/DebugProbe.AspNetCore/Internal/ResourceLoader.cs +++ b/DebugProbe.AspNetCore/Internal/Resources/ResourceLoader.cs @@ -1,6 +1,6 @@ using System.Reflection; -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Resources; /// /// Loads embedded resources (HTML, CSS, JS) from the assembly. @@ -8,7 +8,7 @@ namespace DebugProbe.AspNetCore.Internal; internal static class ResourceLoader { private static readonly Assembly Assembly = typeof(ResourceLoader).Assembly; - private const string Base = "DebugProbe.AspNetCore.Resources."; + private const string Base = "DebugProbe.AspNetCore.Assets."; public static string LoadJs(string file) => Load("js", file); diff --git a/DebugProbe.AspNetCore/Internal/Streams/BoundedResponseCaptureStream.cs b/DebugProbe.AspNetCore/Internal/Streams/BoundedResponseCaptureStream.cs new file mode 100644 index 0000000..563039b --- /dev/null +++ b/DebugProbe.AspNetCore/Internal/Streams/BoundedResponseCaptureStream.cs @@ -0,0 +1,97 @@ +namespace DebugProbe.AspNetCore.Internal.Streams; + +internal sealed class BoundedResponseCaptureStream : Stream +{ + private readonly Stream _inner; + private readonly MemoryStream _capture = new(); + private readonly int _captureLimit; + + public BoundedResponseCaptureStream(Stream inner, int captureLimit) + { + _inner = inner; + _captureLimit = captureLimit; + } + + public long TotalBytesWritten { get; private set; } + + public byte[] CapturedBytes => _capture.ToArray(); + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => _inner.CanWrite; + 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) => + _inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + _inner.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) + { + Capture(buffer.AsSpan(offset, count)); + _inner.Write(buffer, offset, count); + } + + public override async Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + Capture(buffer.AsSpan(offset, count)); + await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + } + + public override void Write(ReadOnlySpan buffer) + { + Capture(buffer); + _inner.Write(buffer); + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + Capture(buffer.Span); + await _inner.WriteAsync(buffer, cancellationToken); + } + + private void Capture(ReadOnlySpan buffer) + { + TotalBytesWritten += buffer.Length; + + var remaining = _captureLimit - (int)_capture.Length; + + if (remaining <= 0) + { + return; + } + + _capture.Write(buffer[..Math.Min(buffer.Length, remaining)]); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _capture.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/DebugProbe.AspNetCore/Internal/EnvironmentUtils.cs b/DebugProbe.AspNetCore/Internal/Utils/EnvironmentUtils.cs similarity index 84% rename from DebugProbe.AspNetCore/Internal/EnvironmentUtils.cs rename to DebugProbe.AspNetCore/Internal/Utils/EnvironmentUtils.cs index 7a49b4e..6b151a5 100644 --- a/DebugProbe.AspNetCore/Internal/EnvironmentUtils.cs +++ b/DebugProbe.AspNetCore/Internal/Utils/EnvironmentUtils.cs @@ -1,4 +1,4 @@ -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Utils; internal static class EnvironmentUtils { diff --git a/DebugProbe.AspNetCore/Internal/JsonUtils.cs b/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs similarity index 95% rename from DebugProbe.AspNetCore/Internal/JsonUtils.cs rename to DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs index 17c2f1d..17e52d7 100644 --- a/DebugProbe.AspNetCore/Internal/JsonUtils.cs +++ b/DebugProbe.AspNetCore/Internal/Utils/JsonUtils.cs @@ -1,7 +1,7 @@ using System.Text.Encodings.Web; using System.Text.Json; -namespace DebugProbe.AspNetCore.Internal; +namespace DebugProbe.AspNetCore.Internal.Utils; internal static class JsonUtils { diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index c72fa25..230ad4e 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using DebugProbe.AspNetCore.Internal.Streams; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; using DebugProbe.AspNetCore.Storage; @@ -13,6 +14,16 @@ namespace DebugProbe.AspNetCore.Middleware; /// public class DebugProbeMiddleware { + private const string BodyTooLargeMessage = "[Body too large]"; + private const string BinaryBodyMessage = "[Body not captured: non-text content]"; + + private static readonly HashSet SensitiveHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Authorization", + "Cookie", + "Set-Cookie" + }; + private readonly RequestDelegate _next; private readonly DebugProbeOptions _options; @@ -46,20 +57,18 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) return; } - context.Request.EnableBuffering(); + var maxBodySize = _options.MaxBodyCaptureSize; - var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(); - context.Request.Body.Position = 0; + var requestBody = await CaptureRequestBodyAsync(context, maxBodySize); var originalBody = context.Response.Body; - using var ms = new MemoryStream(); - context.Response.Body = ms; + await using var responseCapture = new BoundedResponseCaptureStream(originalBody, maxBodySize + 1); + context.Response.Body = responseCapture; var started = Stopwatch.StartNew(); var exception = false; - string? exceptionMessage = null; - string? exceptionStackTrace = null; + string? exceptionResponseBody = null; try { @@ -68,22 +77,17 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) catch (Exception ex) { exception = true; - exceptionMessage = ex.Message; - exceptionStackTrace = ex.StackTrace; + exceptionResponseBody = ex.ToString(); throw; } finally { started.Stop(); - ms.Position = 0; - var responseBody = await new StreamReader(ms).ReadToEndAsync(); - ms.Position = 0; - - await ms.CopyToAsync(originalBody); context.Response.Body = originalBody; var statusCode = exception && context.Response.StatusCode == 200 ? 500 : context.Response.StatusCode; + var responseBody = exception ? Trim(exceptionResponseBody, maxBodySize) : CaptureResponseBody(context, responseCapture, maxBodySize); store.Add(new DebugEntry { @@ -96,23 +100,21 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) StatusCode = statusCode, RequestTimeUtc = DateTime.UtcNow, DurationMs = started.ElapsedMilliseconds, - RequestSize = Encoding.UTF8.GetByteCount(requestBody), + RequestSize = context.Request.ContentLength ?? Encoding.UTF8.GetByteCount(requestBody), ResponseSize = Encoding.UTF8.GetByteCount(responseBody), // Request RequestUrl = $"{context.Request.Scheme}://{context.Request.Host}" + $"{context.Request.Path}{context.Request.QueryString}", - RequestBody = Trim(requestBody), + RequestBody = Trim(requestBody, maxBodySize), // Response - ResponseBody = Trim(responseBody), - - // Exception - ExceptionMessage = exceptionMessage, - ExceptionStackTrace = Trim(exceptionStackTrace, max: 4000), + ResponseBody = Trim(responseBody, maxBodySize), // Headers - Headers = context.Request.Headers.ToDictionary(x => x.Key, x => x.Value.ToString()), + Headers = context.Request.Headers.ToDictionary( + x => x.Key, + x => SensitiveHeaders.Contains(x.Key) ? "[REDACTED]" : x.Value.ToString()), // Other Timestamp = DateTime.UtcNow, @@ -120,7 +122,111 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) } } - private string Trim(string? value, int max = 2000) + private static async Task CaptureRequestBodyAsync(HttpContext context, int maxBodySize) + { + if (!HasBody(context.Request)) + { + return string.Empty; + } + + if (!IsTextContent(context.Request.ContentType)) + { + return BinaryBodyMessage; + } + + if (context.Request.ContentLength > maxBodySize) + { + return BodyTooLargeMessage; + } + + context.Request.EnableBuffering(); + + if (!context.Request.Body.CanSeek) + { + return string.Empty; + } + + context.Request.Body.Position = 0; + var bytes = await ReadAtMostAsync(context.Request.Body, maxBodySize + 1); + context.Request.Body.Position = 0; + + return bytes.Length > maxBodySize + ? BodyTooLargeMessage + : Encoding.UTF8.GetString(bytes); + } + + private static string CaptureResponseBody(HttpContext context, BoundedResponseCaptureStream responseCapture, int maxBodySize) + { + if (!IsTextContent(context.Response.ContentType)) + { + return responseCapture.TotalBytesWritten == 0 + ? string.Empty + : BinaryBodyMessage; + } + + if (responseCapture.TotalBytesWritten > maxBodySize) + { + return BodyTooLargeMessage; + } + + return Encoding.UTF8.GetString(responseCapture.CapturedBytes); + } + + private static bool HasBody(HttpRequest request) + { + if (request.ContentLength == 0) + { + return false; + } + + if (request.ContentLength is > 0) + { + return true; + } + + return string.Equals(request.Method, HttpMethods.Post, StringComparison.OrdinalIgnoreCase) || + string.Equals(request.Method, HttpMethods.Put, StringComparison.OrdinalIgnoreCase) || + string.Equals(request.Method, HttpMethods.Patch, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTextContent(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + return contentType.Contains("json", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("xml", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("text", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("javascript", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("html", StringComparison.OrdinalIgnoreCase) || + contentType.Contains("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase); + } + + private static async Task ReadAtMostAsync(Stream stream, int byteLimit) + { + using var buffer = new MemoryStream(); + var remaining = byteLimit; + var chunk = new byte[Math.Min(81920, byteLimit)]; + + while (remaining > 0) + { + var read = await stream.ReadAsync(chunk.AsMemory(0, Math.Min(chunk.Length, remaining))); + + if (read == 0) + { + break; + } + + await buffer.WriteAsync(chunk.AsMemory(0, read)); + remaining -= read; + } + + return buffer.ToArray(); + } + + private static string Trim(string? value, int max = 2000) { if (string.IsNullOrEmpty(value)) return value ?? string.Empty; return value.Length <= max ? value : value.Substring(0, max); diff --git a/DebugProbe.AspNetCore/Models/DebugEntry.cs b/DebugProbe.AspNetCore/Models/DebugEntry.cs index e101f2d..3ffa07d 100644 --- a/DebugProbe.AspNetCore/Models/DebugEntry.cs +++ b/DebugProbe.AspNetCore/Models/DebugEntry.cs @@ -19,10 +19,6 @@ public class DebugEntry public long ResponseSize { get; set; } public string ResponseBody { get; set; } = default!; - // Exception (captured when middleware catches an unhandled error) - public string? ExceptionMessage { get; set; } - public string? ExceptionStackTrace { get; set; } - // Headers public Dictionary Headers { get; set; } = new(); diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 6feb12b..4190001 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -4,5 +4,10 @@ public class DebugProbeOptions { public int MaxEntries { get; set; } = 20; + public int MaxBodyCaptureSize { get; set; } = 1024 * 256; + + public bool AllowLocalCompareTargets { get; set; } + public string[] IgnorePaths { get; set; } = []; -} \ No newline at end of file + +} diff --git a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs index 6a3ae3a..a72715b 100644 --- a/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs +++ b/DebugProbe.AspNetCore/Storage/DebugEntryStore.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Globalization; using System.Reflection; -using DebugProbe.AspNetCore.Internal; +using DebugProbe.AspNetCore.Internal.Utils; using DebugProbe.AspNetCore.Models; using DebugProbe.AspNetCore.Options; diff --git a/DebugProbe.SampleApi/Program.cs b/DebugProbe.SampleApi/Program.cs index f2fa17e..c268c6b 100644 --- a/DebugProbe.SampleApi/Program.cs +++ b/DebugProbe.SampleApi/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddDebugProbe(options => { options.MaxEntries = 10; + options.AllowLocalCompareTargets = true; }); var app = builder.Build(); From f6aa383c12ce746f13fd068d71d63d227af7fce4 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 16 May 2026 15:29:30 +0300 Subject: [PATCH 2/3] feat(ui): hide payload type badge for truncated bodies --- DebugProbe.AspNetCore/Assets/css/debugprobe.css | 4 ++++ .../Internal/Rendering/HtmlRenderer.cs | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/DebugProbe.AspNetCore/Assets/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css index 12a2c85..32bdda5 100644 --- a/DebugProbe.AspNetCore/Assets/css/debugprobe.css +++ b/DebugProbe.AspNetCore/Assets/css/debugprobe.css @@ -278,6 +278,10 @@ pre { color: #ddd; } +.payload-hidden { + display: none; +} + .diff-badge { background: #4a1717; color: #ff8a8a; diff --git a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs index fe55761..c1739c6 100644 --- a/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs +++ b/DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs @@ -114,6 +114,11 @@ private static string GetStatusClass(int statusCode) private static string GetPayloadType(string value) { + if (IsCapturePlaceholder(value)) + { + return string.Empty; + } + if (string.IsNullOrWhiteSpace(value)) { return "Empty"; @@ -129,6 +134,11 @@ private static string GetPayloadType(string value) private static string GetPayloadTypeClass(string value) { + if (IsCapturePlaceholder(value)) + { + return "payload-hidden"; + } + if (string.IsNullOrWhiteSpace(value)) { return "payload-empty"; @@ -142,6 +152,11 @@ private static string GetPayloadTypeClass(string value) return LooksLikeJson(value) ? "payload-invalid-json" : "payload-text"; } + private static bool IsCapturePlaceholder(string value) + { + return string.Equals(value, "[Body too large]", StringComparison.OrdinalIgnoreCase); + } + private static bool LooksLikeJson(string value) { var trimmed = value.TrimStart(); From a3c1140f6e0f248b18ee811d25d7e86cb6c0cc9b Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sat, 16 May 2026 15:42:18 +0300 Subject: [PATCH 3/3] feat(options): rename max body capture size option to use kilobytes --- DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs | 2 +- DebugProbe.AspNetCore/Options/DebugProbeOptions.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 230ad4e..db9b9fb 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -57,7 +57,7 @@ public async Task Invoke(HttpContext context, DebugEntryStore store) return; } - var maxBodySize = _options.MaxBodyCaptureSize; + var maxBodySize = _options.MaxBodyCaptureSizeKb * 1024; var requestBody = await CaptureRequestBodyAsync(context, maxBodySize); diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 4190001..e42f8a0 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -4,7 +4,7 @@ public class DebugProbeOptions { public int MaxEntries { get; set; } = 20; - public int MaxBodyCaptureSize { get; set; } = 1024 * 256; + public int MaxBodyCaptureSizeKb { get; set; } = 256; public bool AllowLocalCompareTargets { get; set; }