Skip to content

Commit 97fb43b

Browse files
feat: disable DebugProbe UI in production by default
Disable DebugProbe UI endpoints in Production by default and require explicit opt-in when UI access is needed.
2 parents d880b33 + 7eee285 commit 97fb43b

8 files changed

Lines changed: 232 additions & 57 deletions

File tree

‎DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public void Defaults_work_correctly()
1515
Assert.Equal(20, options.MaxEntries);
1616
Assert.Equal(32, options.MaxBodyCaptureSizeKb);
1717
Assert.Null(options.AllowLocalCompareTargets);
18+
Assert.False(options.AllowUiInProduction);
1819
Assert.Empty(options.IgnorePaths);
1920
}
2021

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Net;
2+
using DebugProbe.AspNetCore.Tests.Infrastructure;
3+
using Microsoft.Extensions.Hosting;
4+
5+
namespace DebugProbe.AspNetCore.Tests.Extensions;
6+
7+
public class DebugProbeProductionEndpointTests
8+
{
9+
[Fact]
10+
public async Task Production_does_not_map_ui_endpoints_by_default()
11+
{
12+
await using var app = await DebugProbeWebApplication.CreateAsync(
13+
Environments.Production,
14+
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")));
15+
16+
var capturedResponse = await app.Client.GetAsync("/hello");
17+
var debugResponse = await app.Client.GetAsync("/debug");
18+
var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}");
19+
var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js");
20+
var logoResponse = await app.Client.GetAsync("/debug/logo.png");
21+
var clearResponse = await app.Client.PostAsync("/debug/clear", null);
22+
23+
Assert.Equal(HttpStatusCode.OK, capturedResponse.StatusCode);
24+
Assert.Equal(HttpStatusCode.NotFound, debugResponse.StatusCode);
25+
Assert.Equal(HttpStatusCode.NotFound, comparePageResponse.StatusCode);
26+
Assert.Equal(HttpStatusCode.NotFound, scriptResponse.StatusCode);
27+
Assert.Equal(HttpStatusCode.NotFound, logoResponse.StatusCode);
28+
Assert.Equal(HttpStatusCode.NotFound, clearResponse.StatusCode);
29+
}
30+
31+
[Fact]
32+
public async Task Production_maps_ui_endpoints_when_explicitly_allowed()
33+
{
34+
await using var app = await DebugProbeWebApplication.CreateAsync(
35+
Environments.Production,
36+
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")),
37+
options => options.AllowUiInProduction = true);
38+
39+
await app.Client.GetAsync("/hello");
40+
41+
var debugResponse = await app.Client.GetAsync("/debug");
42+
var detailsResponse = await app.Client.GetAsync($"/debug/{app.SingleEntry.Id}");
43+
var comparePageResponse = await app.Client.GetAsync($"/compare?localTraceId={app.SingleEntry.Id}");
44+
var scriptResponse = await app.Client.GetAsync("/debug/js/debugprobe-ui.js");
45+
var logoResponse = await app.Client.GetAsync("/debug/logo.png");
46+
var clearResponse = await app.Client.PostAsync("/debug/clear", null);
47+
48+
Assert.Equal(HttpStatusCode.OK, debugResponse.StatusCode);
49+
Assert.Equal(HttpStatusCode.OK, detailsResponse.StatusCode);
50+
Assert.Equal(HttpStatusCode.OK, comparePageResponse.StatusCode);
51+
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
52+
Assert.Equal(HttpStatusCode.OK, logoResponse.StatusCode);
53+
Assert.Equal(HttpStatusCode.OK, clearResponse.StatusCode);
54+
}
55+
56+
[Fact]
57+
public async Task Production_keeps_machine_readable_debug_endpoints_available_by_default()
58+
{
59+
await using var app = await DebugProbeWebApplication.CreateAsync(
60+
Environments.Production,
61+
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")));
62+
63+
await app.Client.GetAsync("/hello");
64+
65+
var environmentResponse = await app.Client.GetAsync("/debug/environment");
66+
var jsonResponse = await app.Client.GetAsync($"/debug/json/{app.SingleEntry.Id}");
67+
68+
Assert.Equal(HttpStatusCode.OK, environmentResponse.StatusCode);
69+
Assert.Equal(HttpStatusCode.OK, jsonResponse.StatusCode);
70+
}
71+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using DebugProbe.AspNetCore.Extensions;
2+
using DebugProbe.AspNetCore.Models;
3+
using DebugProbe.AspNetCore.Options;
4+
using DebugProbe.AspNetCore.Storage;
5+
using Microsoft.AspNetCore.Routing;
6+
using Microsoft.AspNetCore.TestHost;
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace DebugProbe.AspNetCore.Tests.Infrastructure;
10+
11+
internal sealed class DebugProbeWebApplication : IAsyncDisposable
12+
{
13+
private readonly WebApplication _app;
14+
15+
private DebugProbeWebApplication(WebApplication app)
16+
{
17+
_app = app;
18+
Client = app.GetTestClient();
19+
Store = app.Services.GetRequiredService<DebugEntryStore>();
20+
}
21+
22+
public HttpClient Client { get; }
23+
24+
public DebugEntryStore Store { get; }
25+
26+
public DebugEntry SingleEntry => Assert.Single(Store.GetAll());
27+
28+
public static async Task<DebugProbeWebApplication> CreateAsync(
29+
string environmentName,
30+
Action<IEndpointRouteBuilder>? mapEndpoints = null,
31+
Action<DebugProbeOptions>? configureOptions = null)
32+
{
33+
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
34+
{
35+
EnvironmentName = environmentName
36+
});
37+
38+
builder.WebHost.UseTestServer();
39+
40+
builder.Services.AddRouting();
41+
builder.Services.AddDebugProbe(configureOptions);
42+
43+
var app = builder.Build();
44+
45+
app.UseRouting();
46+
app.UseDebugProbe();
47+
48+
mapEndpoints?.Invoke(app);
49+
50+
await app.StartAsync();
51+
52+
return new DebugProbeWebApplication(app);
53+
}
54+
55+
public async ValueTask DisposeAsync()
56+
{
57+
Client.Dispose();
58+
await _app.DisposeAsync();
59+
}
60+
}

‎DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs‎

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -71,52 +71,82 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
7171

7272
if (app is WebApplication webApp)
7373
{
74-
webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
74+
if (ShouldMapUiEndpoints(environment, options))
7575
{
76-
var items = store.GetAll()
77-
.OrderByDescending(x => x.Timestamp)
78-
.ToList();
76+
webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
77+
{
78+
var items = store.GetAll()
79+
.OrderByDescending(x => x.Timestamp)
80+
.ToList();
7981

80-
var html = HtmlRenderer.RenderIndexPage(items);
81-
ctx.Response.ContentType = "text/html";
82+
var html = HtmlRenderer.RenderIndexPage(items);
83+
ctx.Response.ContentType = "text/html";
8284

83-
await ctx.Response.WriteAsync(html);
85+
await ctx.Response.WriteAsync(html);
8486

85-
}).ExcludeFromDescription();
87+
}).ExcludeFromDescription();
8688

87-
webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
88-
{
89-
var item = store.Get(id);
89+
webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
90+
{
91+
var item = store.Get(id);
92+
93+
if (item is null)
94+
{
95+
ctx.Response.StatusCode = 404;
96+
await ctx.Response.WriteAsync("Not found");
97+
return;
98+
}
99+
100+
var prettyRequest = JsonUtils.Format(item.RequestBody);
101+
var prettyResponse = JsonUtils.Format(item.ResponseBody);
102+
103+
var html = HtmlRenderer.RenderDetailsPage(item, store.Environment, prettyRequest, prettyResponse);
104+
ctx.Response.ContentType = "text/html";
105+
106+
await ctx.Response.WriteAsync(html);
90107

91-
if (item is null)
108+
}).ExcludeFromDescription();
109+
110+
webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) =>
92111
{
93-
ctx.Response.StatusCode = 404;
94-
await ctx.Response.WriteAsync("Not found");
95-
return;
96-
}
112+
if (string.IsNullOrWhiteSpace(localTraceId))
113+
{
114+
return Results.BadRequest("Missing local trace id");
115+
}
97116

98-
var prettyRequest = JsonUtils.Format(item.RequestBody);
99-
var prettyResponse = JsonUtils.Format(item.ResponseBody);
117+
var html = HtmlRenderer.RenderComparePage(localTraceId, baseUrl ?? "", traceId ?? "");
100118

101-
var html = HtmlRenderer.RenderDetailsPage(item, store.Environment, prettyRequest, prettyResponse);
102-
ctx.Response.ContentType = "text/html";
119+
return Results.Content(html, "text/html");
103120

104-
await ctx.Response.WriteAsync(html);
121+
}).ExcludeFromDescription();
105122

106-
}).ExcludeFromDescription();
123+
webApp.MapGet("/debug/js/{file}", (string file) =>
124+
{
125+
if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content))
126+
{
127+
return Results.NotFound();
128+
}
107129

108-
webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) =>
109-
{
110-
if (string.IsNullOrWhiteSpace(localTraceId))
130+
return Results.Text(content, "application/javascript");
131+
132+
}).ExcludeFromDescription();
133+
134+
webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
111135
{
112-
return Results.BadRequest("Missing local trace id");
113-
}
136+
store.Clear();
114137

115-
var html = HtmlRenderer.RenderComparePage(localTraceId, baseUrl ?? "", traceId ?? "");
138+
return Results.Ok();
116139

117-
return Results.Content(html, "text/html");
140+
}).ExcludeFromDescription();
118141

119-
}).ExcludeFromDescription();
142+
webApp.Map("/debug/logo.png", ctx =>
143+
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png")
144+
).ExcludeFromDescription();
145+
146+
webApp.Map("/debug/favicon.ico", ctx =>
147+
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon")
148+
).ExcludeFromDescription();
149+
}
120150

121151
webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
122152
DebugEntryStore store,
@@ -212,34 +242,13 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
212242
}).ExcludeFromDescription();
213243

214244

215-
webApp.MapGet("/debug/js/{file}", (string file) =>
216-
{
217-
if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content))
218-
{
219-
return Results.NotFound();
220-
}
221-
222-
return Results.Text(content, "application/javascript");
223-
224-
}).ExcludeFromDescription();
225-
226-
webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
227-
{
228-
store.Clear();
229-
230-
return Results.Ok();
231-
232-
}).ExcludeFromDescription();
233-
234-
webApp.Map("/debug/logo.png", ctx =>
235-
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png")
236-
).ExcludeFromDescription();
237-
238-
webApp.Map("/debug/favicon.ico", ctx =>
239-
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon")
240-
).ExcludeFromDescription();
241245
}
242246

243247
return app;
244248
}
249+
250+
private static bool ShouldMapUiEndpoints(IHostEnvironment environment, DebugProbeOptions options)
251+
{
252+
return !environment.IsProduction() || options.AllowUiInProduction;
253+
}
245254
}

‎DebugProbe.AspNetCore/Options/DebugProbeOptions.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public class DebugProbeOptions
2323
/// </summary>
2424
public bool? AllowLocalCompareTargets { get; set; }
2525

26+
/// <summary>
27+
/// Allows DebugProbe UI endpoints to be registered in Production.
28+
/// Defaults to false.
29+
/// </summary>
30+
public bool AllowUiInProduction { get; set; }
31+
2632
/// <summary>
2733
/// Additional request paths to ignore.
2834
/// </summary>

‎DebugProbe.AspNetCore/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Start your application and open:
3333
http://localhost:{port}/debug
3434
```
3535

36+
In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled.
37+
3638
## Optional Configuration
3739

3840
```csharp
@@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options =>
4446

4547
options.AllowLocalCompareTargets = true;
4648

49+
options.AllowUiInProduction = false;
50+
4751
options.IgnorePaths =
4852
[
4953
"/api/auth/login",
@@ -85,6 +89,15 @@ Dynamic values such as IDs, timestamps, tokens, and selected headers are normali
8589

8690
## Security Defaults
8791

92+
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:
93+
94+
```csharp
95+
builder.Services.AddDebugProbe(options =>
96+
{
97+
options.AllowUiInProduction = true;
98+
});
99+
```
100+
88101
DebugProbe masks common sensitive headers automatically:
89102

90103
- `Authorization`

‎DebugProbe.SampleApi/Program.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
builder.Services.AddDebugProbe(options =>
1212
{
1313
options.MaxEntries = 10;
14+
options.AllowUiInProduction = true;
1415
});
1516

1617
var app = builder.Build();
@@ -20,9 +21,10 @@
2021
{
2122
app.UseSwagger();
2223
app.UseSwaggerUI();
23-
app.UseDebugProbe();
2424
}
2525

26+
app.UseDebugProbe();
27+
2628
app.UseHttpsRedirection();
2729

2830
app.UseAuthorization();

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ Start your application and open:
3333
http://localhost:{port}/debug
3434
```
3535

36+
In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled.
37+
3638
## Optional Configuration
3739

3840
```csharp
@@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options =>
4446

4547
options.AllowLocalCompareTargets = true;
4648

49+
options.AllowUiInProduction = false;
50+
4751
options.IgnorePaths =
4852
[
4953
"/api/auth/login",
@@ -85,6 +89,15 @@ Dynamic values such as IDs, timestamps, tokens, and selected headers are normali
8589

8690
## Security Defaults
8791

92+
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:
93+
94+
```csharp
95+
builder.Services.AddDebugProbe(options =>
96+
{
97+
options.AllowUiInProduction = true;
98+
});
99+
```
100+
88101
DebugProbe masks common sensitive headers automatically:
89102

90103
- `Authorization`

0 commit comments

Comments
 (0)