Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<DebugEntryStore>();
}

public HttpClient Client { get; }

public DebugEntryStore Store { get; }

public DebugEntry SingleEntry => Assert.Single(Store.GetAll());

public static async Task<DebugProbeWebApplication> CreateAsync(
string environmentName,
Action<IEndpointRouteBuilder>? mapEndpoints = null,
Action<DebugProbeOptions>? 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();
}
}
121 changes: 65 additions & 56 deletions DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
}
6 changes: 6 additions & 0 deletions DebugProbe.AspNetCore/Options/DebugProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ public class DebugProbeOptions
/// </summary>
public bool? AllowLocalCompareTargets { get; set; }

/// <summary>
/// Allows DebugProbe UI endpoints to be registered in Production.
/// Defaults to false.
/// </summary>
public bool AllowUiInProduction { get; set; }

/// <summary>
/// Additional request paths to ignore.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions DebugProbe.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options =>

options.AllowLocalCompareTargets = true;

options.AllowUiInProduction = false;

options.IgnorePaths =
[
"/api/auth/login",
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 3 additions & 1 deletion DebugProbe.SampleApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
builder.Services.AddDebugProbe(options =>
{
options.MaxEntries = 10;
options.AllowUiInProduction = true;
});

var app = builder.Build();
Expand All @@ -20,9 +21,10 @@
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseDebugProbe();
}

app.UseDebugProbe();

app.UseHttpsRedirection();

app.UseAuthorization();
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,6 +46,8 @@ builder.Services.AddDebugProbe(options =>

options.AllowLocalCompareTargets = true;

options.AllowUiInProduction = false;

options.IgnorePaths =
[
"/api/auth/login",
Expand Down Expand Up @@ -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`
Expand Down
Loading