diff --git a/DebugProbe.AspNetCore/Resources/css/debugprobe.css b/DebugProbe.AspNetCore/Assets/css/debugprobe.css
similarity index 98%
rename from DebugProbe.AspNetCore/Resources/css/debugprobe.css
rename to DebugProbe.AspNetCore/Assets/css/debugprobe.css
index aef0974..32bdda5 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
========================= */
@@ -274,6 +278,10 @@ pre {
color: #ddd;
}
+.payload-hidden {
+ display: none;
+}
+
.diff-badge {
background: #4a1717;
color: #ff8a8a;
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 88%
rename from DebugProbe.AspNetCore/Internal/HtmlRenderer.cs
rename to DebugProbe.AspNetCore/Internal/Rendering/HtmlRenderer.cs
index 6f847a0..c1739c6 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)
@@ -112,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";
@@ -127,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";
@@ -140,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();
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..db9b9fb 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.MaxBodyCaptureSizeKb * 1024;
- 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..e42f8a0 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 MaxBodyCaptureSizeKb { get; set; } = 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();