From 17874e16e24a767eeb975cd14592353c788b6588 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 4 Jun 2026 18:34:01 +0300 Subject: [PATCH 1/2] feat(ui): disable DebugProbe UI in production by default --- .../Extensions/DebugProbeExtensions.cs | 121 ++++++++++-------- .../Options/DebugProbeOptions.cs | 6 + DebugProbe.AspNetCore/README.md | 13 ++ DebugProbe.SampleApi/Program.cs | 4 +- README.md | 13 ++ 5 files changed, 100 insertions(+), 57 deletions(-) diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index 06c6bb5..2e2ded7 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -71,52 +71,82 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) if (app is WebApplication webApp) { - webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) => + if (ShouldMapUiEndpoints(environment, options)) { - var items = store.GetAll() - .OrderByDescending(x => x.Timestamp) - .ToList(); + webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) => + { + var items = store.GetAll() + .OrderByDescending(x => x.Timestamp) + .ToList(); - var html = HtmlRenderer.RenderIndexPage(items); - ctx.Response.ContentType = "text/html"; + var html = HtmlRenderer.RenderIndexPage(items); + ctx.Response.ContentType = "text/html"; - await ctx.Response.WriteAsync(html); + await ctx.Response.WriteAsync(html); - }).ExcludeFromDescription(); + }).ExcludeFromDescription(); - webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) => - { - var item = store.Get(id); + webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) => + { + var item = store.Get(id); + + if (item is null) + { + ctx.Response.StatusCode = 404; + await ctx.Response.WriteAsync("Not found"); + return; + } + + var prettyRequest = JsonUtils.Format(item.RequestBody); + var prettyResponse = JsonUtils.Format(item.ResponseBody); + + var html = HtmlRenderer.RenderDetailsPage(item, store.Environment, prettyRequest, prettyResponse); + ctx.Response.ContentType = "text/html"; + + await ctx.Response.WriteAsync(html); - if (item is null) + }).ExcludeFromDescription(); + + webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) => { - ctx.Response.StatusCode = 404; - await ctx.Response.WriteAsync("Not found"); - return; - } + if (string.IsNullOrWhiteSpace(localTraceId)) + { + return Results.BadRequest("Missing local trace id"); + } - var prettyRequest = JsonUtils.Format(item.RequestBody); - var prettyResponse = JsonUtils.Format(item.ResponseBody); + var html = HtmlRenderer.RenderComparePage(localTraceId, baseUrl ?? "", traceId ?? ""); - var html = HtmlRenderer.RenderDetailsPage(item, store.Environment, prettyRequest, prettyResponse); - ctx.Response.ContentType = "text/html"; + return Results.Content(html, "text/html"); - await ctx.Response.WriteAsync(html); + }).ExcludeFromDescription(); - }).ExcludeFromDescription(); + webApp.MapGet("/debug/js/{file}", (string file) => + { + if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content)) + { + return Results.NotFound(); + } - webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) => - { - if (string.IsNullOrWhiteSpace(localTraceId)) + return Results.Text(content, "application/javascript"); + + }).ExcludeFromDescription(); + + webApp.MapPost("/debug/clear", (DebugEntryStore store) => { - return Results.BadRequest("Missing local trace id"); - } + store.Clear(); - var html = HtmlRenderer.RenderComparePage(localTraceId, baseUrl ?? "", traceId ?? ""); + return Results.Ok(); - return Results.Content(html, "text/html"); + }).ExcludeFromDescription(); - }).ExcludeFromDescription(); + webApp.Map("/debug/logo.png", ctx => + EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png") + ).ExcludeFromDescription(); + + webApp.Map("/debug/favicon.ico", ctx => + EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon") + ).ExcludeFromDescription(); + } webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId, DebugEntryStore store, @@ -212,34 +242,13 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) }).ExcludeFromDescription(); - webApp.MapGet("/debug/js/{file}", (string file) => - { - if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content)) - { - return Results.NotFound(); - } - - return Results.Text(content, "application/javascript"); - - }).ExcludeFromDescription(); - - webApp.MapPost("/debug/clear", (DebugEntryStore store) => - { - store.Clear(); - - return Results.Ok(); - - }).ExcludeFromDescription(); - - webApp.Map("/debug/logo.png", ctx => - EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png") - ).ExcludeFromDescription(); - - webApp.Map("/debug/favicon.ico", ctx => - EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon") - ).ExcludeFromDescription(); } return app; } + + private static bool ShouldMapUiEndpoints(IHostEnvironment environment, DebugProbeOptions options) + { + return !environment.IsProduction() || options.AllowUiInProduction; + } } diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 7d4e6e0..44d4a51 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -23,6 +23,12 @@ public class DebugProbeOptions /// public bool? AllowLocalCompareTargets { get; set; } + /// + /// Allows DebugProbe UI endpoints to be registered in Production. + /// Defaults to false. + /// + public bool AllowUiInProduction { get; set; } + /// /// Additional request paths to ignore. /// diff --git a/DebugProbe.AspNetCore/README.md b/DebugProbe.AspNetCore/README.md index 9c5af36..cbabec8 100644 --- a/DebugProbe.AspNetCore/README.md +++ b/DebugProbe.AspNetCore/README.md @@ -33,6 +33,8 @@ Start your application and open: http://localhost:{port}/debug ``` +In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled. + ## Optional Configuration ```csharp @@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options => options.AllowLocalCompareTargets = true; + options.AllowUiInProduction = false; + options.IgnorePaths = [ "/api/auth/login", @@ -85,6 +89,15 @@ Dynamic values such as IDs, timestamps, tokens, and selected headers are normali ## Security Defaults +DebugProbe UI endpoints are disabled by default in Production. Capture and trace storage continue to run, but the dashboard, trace viewer, compare UI, UI assets, and UI clear action are not registered unless explicitly enabled: + +```csharp +builder.Services.AddDebugProbe(options => +{ + options.AllowUiInProduction = true; +}); +``` + DebugProbe masks common sensitive headers automatically: - `Authorization` diff --git a/DebugProbe.SampleApi/Program.cs b/DebugProbe.SampleApi/Program.cs index f2fa17e..b9fb554 100644 --- a/DebugProbe.SampleApi/Program.cs +++ b/DebugProbe.SampleApi/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddDebugProbe(options => { options.MaxEntries = 10; + options.AllowUiInProduction = true; }); var app = builder.Build(); @@ -20,9 +21,10 @@ { app.UseSwagger(); app.UseSwaggerUI(); - app.UseDebugProbe(); } +app.UseDebugProbe(); + app.UseHttpsRedirection(); app.UseAuthorization(); diff --git a/README.md b/README.md index 7d55662..49d7cb1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Start your application and open: http://localhost:{port}/debug ``` +In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled. + ## Optional Configuration ```csharp @@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options => options.AllowLocalCompareTargets = true; + options.AllowUiInProduction = false; + options.IgnorePaths = [ "/api/auth/login", @@ -85,6 +89,15 @@ Dynamic values such as IDs, timestamps, tokens, and selected headers are normali ## Security Defaults +DebugProbe UI endpoints are disabled by default in Production. Capture and trace storage continue to run, but the dashboard, trace viewer, compare UI, UI assets, and UI clear action are not registered unless explicitly enabled: + +```csharp +builder.Services.AddDebugProbe(options => +{ + options.AllowUiInProduction = true; +}); +``` + DebugProbe masks common sensitive headers automatically: - `Authorization` From 7eee285c2577cd9df9e4070b46c45368ab935cf8 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 4 Jun 2026 19:38:59 +0300 Subject: [PATCH 2/2] test: update test project for production UI scenarios --- .../Configuration/DebugProbeOptionsTests.cs | 1 + .../DebugProbeProductionEndpointTests.cs | 71 +++++++++++++++++++ .../DebugProbeWebApplication.cs | 60 ++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs create mode 100644 DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index 6ceefea..44d00d4 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -15,6 +15,7 @@ public void Defaults_work_correctly() Assert.Equal(20, options.MaxEntries); Assert.Equal(32, options.MaxBodyCaptureSizeKb); Assert.Null(options.AllowLocalCompareTargets); + Assert.False(options.AllowUiInProduction); Assert.Empty(options.IgnorePaths); } diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs new file mode 100644 index 0000000..093ca38 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeProductionEndpointTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using DebugProbe.AspNetCore.Tests.Infrastructure; +using Microsoft.Extensions.Hosting; + +namespace DebugProbe.AspNetCore.Tests.Extensions; + +public class DebugProbeProductionEndpointTests +{ + [Fact] + public async Task Production_does_not_map_ui_endpoints_by_default() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok"))); + + var capturedResponse = await app.Client.GetAsync("/hello"); + var debugResponse = await app.Client.GetAsync("/debug"); + var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}"); + var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js"); + var logoResponse = await app.Client.GetAsync("/debug/logo.png"); + var clearResponse = await app.Client.PostAsync("/debug/clear", null); + + Assert.Equal(HttpStatusCode.OK, capturedResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, debugResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, comparePageResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, scriptResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, logoResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, clearResponse.StatusCode); + } + + [Fact] + public async Task Production_maps_ui_endpoints_when_explicitly_allowed() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")), + options => options.AllowUiInProduction = true); + + await app.Client.GetAsync("/hello"); + + var debugResponse = await app.Client.GetAsync("/debug"); + var detailsResponse = await app.Client.GetAsync($"/debug/{app.SingleEntry.Id}"); + var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}"); + var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js"); + var logoResponse = await app.Client.GetAsync("/debug/logo.png"); + var clearResponse = await app.Client.PostAsync("/debug/clear", null); + + Assert.Equal(HttpStatusCode.OK, debugResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, comparePageResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, logoResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, clearResponse.StatusCode); + } + + [Fact] + public async Task Production_keeps_machine_readable_debug_endpoints_available_by_default() + { + await using var app = await DebugProbeWebApplication.CreateAsync( + Environments.Production, + endpoints => endpoints.MapGet("/hello", () => Results.Text("ok"))); + + await app.Client.GetAsync("/hello"); + + var environmentResponse = await app.Client.GetAsync("/debug/environment"); + var jsonResponse = await app.Client.GetAsync($"/debug/json/{app.SingleEntry.Id}"); + + Assert.Equal(HttpStatusCode.OK, environmentResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, jsonResponse.StatusCode); + } +} diff --git a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs new file mode 100644 index 0000000..6ee5702 --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs @@ -0,0 +1,60 @@ +using DebugProbe.AspNetCore.Extensions; +using DebugProbe.AspNetCore.Models; +using DebugProbe.AspNetCore.Options; +using DebugProbe.AspNetCore.Storage; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace DebugProbe.AspNetCore.Tests.Infrastructure; + +internal sealed class DebugProbeWebApplication : IAsyncDisposable +{ + private readonly WebApplication _app; + + private DebugProbeWebApplication(WebApplication app) + { + _app = app; + Client = app.GetTestClient(); + Store = app.Services.GetRequiredService(); + } + + public HttpClient Client { get; } + + public DebugEntryStore Store { get; } + + public DebugEntry SingleEntry => Assert.Single(Store.GetAll()); + + public static async Task CreateAsync( + string environmentName, + Action? mapEndpoints = null, + Action? configureOptions = null) + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = environmentName + }); + + builder.WebHost.UseTestServer(); + + builder.Services.AddRouting(); + builder.Services.AddDebugProbe(configureOptions); + + var app = builder.Build(); + + app.UseRouting(); + app.UseDebugProbe(); + + mapEndpoints?.Invoke(app); + + await app.StartAsync(); + + return new DebugProbeWebApplication(app); + } + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await _app.DisposeAsync(); + } +}