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 @@ -16,6 +16,7 @@ public void Defaults_work_correctly()
Assert.Equal(32, options.MaxBodyCaptureSizeKb);
Assert.Null(options.AllowLocalCompareTargets);
Assert.False(options.AllowUiInProduction);
Assert.Null(options.AuthorizationPolicy);
Assert.True(options.CaptureOutgoingHttpClientRequests);
Assert.Empty(options.IgnorePaths);
Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders);
Expand All @@ -34,6 +35,7 @@ public void Custom_options_are_registered_and_used()
options.MaxEntries = 2;
options.MaxBodyCaptureSizeKb = 4;
options.AllowLocalCompareTargets = true;
options.AuthorizationPolicy = "DebugProbePolicy";
options.IgnorePaths = ["/health"];
options.CaptureOutgoingHttpClientRequests = false;
options.RedactedHeaders = ["X-Api-Key"];
Expand All @@ -49,6 +51,7 @@ public void Custom_options_are_registered_and_used()
Assert.Equal(2, options.MaxEntries);
Assert.Equal(4, options.MaxBodyCaptureSizeKb);
Assert.True(options.AllowLocalCompareTargets);
Assert.Equal("DebugProbePolicy", options.AuthorizationPolicy);
Assert.Equal(["/health"], options.IgnorePaths);
Assert.False(options.CaptureOutgoingHttpClientRequests);
Assert.Equal(["X-Api-Key"], options.RedactedHeaders);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Net;
using System.Security.Claims;
using System.Text.Encodings.Web;
using DebugProbe.AspNetCore.Tests.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace DebugProbe.AspNetCore.Tests.Extensions;

public class DebugProbeAuthorizationEndpointTests
{
[Fact]
public async Task Debug_endpoints_do_not_require_authorization_by_default()
{
await using var app = await DebugProbeWebApplication.CreateAsync(
Environments.Development,
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")));

await app.Client.GetAsync("/hello");

var debugResponse = await app.Client.GetAsync("/debug");
var jsonResponse = await app.Client.GetAsync($"/debug/json/{app.SingleEntry.Id}");

Assert.Equal(HttpStatusCode.OK, debugResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, jsonResponse.StatusCode);
}

[Fact]
public async Task Debug_endpoints_require_configured_authorization_policy()
{
await using var app = await DebugProbeWebApplication.CreateAsync(
Environments.Development,
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")),
configureServices: services =>
{
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName,
_ => { });

services.AddAuthorization(options =>
{
options.AddPolicy("DebugProbePolicy", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireRole("Admin");
});
});
},
configureBeforeDebugProbe: app =>
{
app.UseAuthentication();
app.UseAuthorization();
},
configureUseDebugProbe: options => options.AuthorizationPolicy = "DebugProbePolicy");

await app.Client.GetAsync("/hello");
var traceId = app.SingleEntry.Id;

var unauthorizedDebugResponse = await app.Client.GetAsync("/debug");
var unauthorizedJsonResponse = await app.Client.GetAsync($"/debug/json/{traceId}");

using var authorizedRequest = new HttpRequestMessage(HttpMethod.Get, "/debug");
authorizedRequest.Headers.Add(TestAuthHandler.RoleHeaderName, "Admin");
var authorizedDebugResponse = await app.Client.SendAsync(authorizedRequest);

using var authorizedJsonRequest = new HttpRequestMessage(HttpMethod.Get, $"/debug/json/{traceId}");
authorizedJsonRequest.Headers.Add(TestAuthHandler.RoleHeaderName, "Admin");
var authorizedJsonResponse = await app.Client.SendAsync(authorizedJsonRequest);

Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedDebugResponse.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedJsonResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, authorizedDebugResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, authorizedJsonResponse.StatusCode);
}

private sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
public const string RoleHeaderName = "X-Test-Role";

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(RoleHeaderName, out var role))
{
return Task.FromResult(AuthenticateResult.NoResult());
}

var claims = new[]
{
new Claim(ClaimTypes.Name, "Test User"),
new Claim(ClaimTypes.Role, role.ToString())
};

var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);

return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ private DebugProbeWebApplication(WebApplication app)
public static async Task<DebugProbeWebApplication> CreateAsync(
string environmentName,
Action<IEndpointRouteBuilder>? mapEndpoints = null,
Action<DebugProbeOptions>? configureOptions = null)
Action<DebugProbeOptions>? configureOptions = null,
Action<IServiceCollection>? configureServices = null,
Action<WebApplication>? configureBeforeDebugProbe = null,
Action<DebugProbeOptions>? configureUseDebugProbe = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Expand All @@ -39,11 +42,13 @@ public static async Task<DebugProbeWebApplication> CreateAsync(

builder.Services.AddRouting();
builder.Services.AddDebugProbe(configureOptions);
configureServices?.Invoke(builder.Services);

var app = builder.Build();

app.UseRouting();
app.UseDebugProbe();
configureBeforeDebugProbe?.Invoke(app);
app.UseDebugProbe(configureUseDebugProbe);

mapEndpoints?.Invoke(app);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ public async Task Ignored_paths_are_skipped()
Assert.Empty(app.Store.GetAll());
}

[Theory]
[InlineData("/health")]
[InlineData("/healthz")]
[InlineData("/ready")]
[InlineData("/live")]
public async Task Default_health_probe_paths_are_skipped(string path)
{
await using var app = await DebugProbeTestApp.CreateAsync(endpoints =>
{
endpoints.MapGet(path, () => Results.Ok());
});

var response = await app.Client.GetAsync(path);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(app.Store.GetAll());
}

[Fact]
public async Task Debug_paths_are_skipped()
{
Expand Down
4 changes: 2 additions & 2 deletions DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<NoWarn>1591</NoWarn>

<PackageId>DebugProbe.AspNetCore</PackageId>
<Version>1.6.2</Version>
<Version>1.6.3</Version>

<Authors>Georgi Hristov</Authors>

Expand All @@ -17,7 +17,7 @@
<PackageTags>aspnetcore;debugging;http;middleware;diagnostics;tracing;observability;api-debugging;developer-tools;trace-comparison;environment-comparison</PackageTags>

<PackageReleaseNotes>
Adds configurable sensitive data redaction for headers, query parameters, and JSON body fields. Improves dashboard handling of long request paths, enables local compare targets by default in Development, and introduces AllowUiInProduction to disable DebugProbe UI endpoints in Production unless explicitly enabled.
Adds optional ASP.NET Core authorization policy support for DebugProbe endpoints through DebugProbeOptions.AuthorizationPolicy and the new UseDebugProbe(options =&gt; ...) overload. DebugProbe endpoints remain unsecured by default for backward compatibility, but can now be protected with existing authentication, roles, claims, or custom authorization policies. Also ignores common health/readiness probe paths by default (/health, /healthz, /ready, /live) to reduce dashboard noise and preserve useful trace history. Updates README and security guidance with policy-protected endpoint examples.
</PackageReleaseNotes>

<PackageIcon>icon.png</PackageIcon>
Expand Down
59 changes: 39 additions & 20 deletions DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using DebugProbe.AspNetCore.Models;
using DebugProbe.AspNetCore.Options;
using DebugProbe.AspNetCore.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -63,10 +64,20 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services,
/// Registers DebugProbe services.
/// </summary>
public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
{
return app.UseDebugProbe(configure: null);
}

/// <summary>
/// Registers DebugProbe services and configures runtime endpoint options.
/// </summary>
public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app, Action<DebugProbeOptions>? configure)
{
var options = app.ApplicationServices.GetRequiredService<DebugProbeOptions>();
var environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>();

configure?.Invoke(options);

options.AllowLocalCompareTargets ??= environment.IsDevelopment();

app.UseMiddleware<DebugProbeMiddleware>();
Expand All @@ -76,7 +87,7 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
{
if (ShouldMapUiEndpoints(environment, options))
{
webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
RequireDebugAuthorization(webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
{
var items = store.GetAll()
.OrderByDescending(x => x.Timestamp)
Expand All @@ -87,9 +98,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)

await ctx.Response.WriteAsync(html);

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
RequireDebugAuthorization(webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
{
var item = store.Get(id);

Expand All @@ -108,9 +119,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)

await ctx.Response.WriteAsync(html);

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) =>
RequireDebugAuthorization(webApp.MapGet("/compare", (string? baseUrl, string? traceId, string? localTraceId) =>
{
if (string.IsNullOrWhiteSpace(localTraceId))
{
Expand All @@ -121,9 +132,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)

return Results.Content(html, "text/html");

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapGet("/debug/js/{file}", (string file) =>
RequireDebugAuthorization(webApp.MapGet("/debug/js/{file}", (string file) =>
{
if (!EmbeddedResources.JavaScript.TryGetValue(file, out var content))
{
Expand All @@ -132,26 +143,26 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)

return Results.Text(content, "application/javascript");

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
RequireDebugAuthorization(webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
{
store.Clear();

return Results.Ok();

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.Map("/debug/logo.png", ctx =>
RequireDebugAuthorization(webApp.Map("/debug/logo.png", ctx =>
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png")
).ExcludeFromDescription();
).ExcludeFromDescription(), options);

webApp.Map("/debug/favicon.ico", ctx =>
RequireDebugAuthorization(webApp.Map("/debug/favicon.ico", ctx =>
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon")
).ExcludeFromDescription();
).ExcludeFromDescription(), options);
}

webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
RequireDebugAuthorization(webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
DebugEntryStore store,
DebugProbeOptions options) =>
{
Expand Down Expand Up @@ -228,21 +239,21 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
diffs = diff
});

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapGet("/debug/environment", (DebugEntryStore store) =>
RequireDebugAuthorization(webApp.MapGet("/debug/environment", (DebugEntryStore store) =>
{
return Results.Ok(store.Environment);

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);

webApp.MapGet("/debug/json/{id}", (string id, DebugEntryStore store) =>
RequireDebugAuthorization(webApp.MapGet("/debug/json/{id}", (string id, DebugEntryStore store) =>
{
var item = store.Get(id);

return item is null ? Results.NotFound() : Results.Json(item);

}).ExcludeFromDescription();
}).ExcludeFromDescription(), options);


}
Expand All @@ -254,4 +265,12 @@ private static bool ShouldMapUiEndpoints(IHostEnvironment environment, DebugProb
{
return !environment.IsProduction() || options.AllowUiInProduction;
}

private static void RequireDebugAuthorization(IEndpointConventionBuilder endpoint, DebugProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.AuthorizationPolicy))
{
endpoint.RequireAuthorization(options.AuthorizationPolicy);
}
}
}
4 changes: 4 additions & 0 deletions DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public class DebugProbeMiddleware
"/debug",
"/compare",
"/swagger",
"/health",
"/healthz",
"/ready",
"/live",
"/.well-known",

// browser noise
Expand Down
6 changes: 6 additions & 0 deletions DebugProbe.AspNetCore/Options/DebugProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ public class DebugProbeOptions
/// </summary>
public bool AllowUiInProduction { get; set; }

/// <summary>
/// Optional ASP.NET Core authorization policy required for DebugProbe endpoints.
/// When not configured, DebugProbe endpoints do not require authorization.
/// </summary>
public string? AuthorizationPolicy { get; set; }

/// <summary>
/// Captures outgoing requests made through IHttpClientFactory.
/// Defaults to true.
Expand Down
Loading
Loading