From 7b48f1c14af0e0d1b60e5b1cab9cbbdcaf8ddac7 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 11 Jun 2026 12:12:54 +0300 Subject: [PATCH] feat(security): add authorization policy support and default health endpoint exclusions --- .../Configuration/DebugProbeOptionsTests.cs | 3 + .../DebugProbeAuthorizationEndpointTests.cs | 109 ++++++++++++++++++ .../DebugProbeWebApplication.cs | 9 +- .../MiddlewareExecutionFlowTests.cs | 18 +++ .../DebugProbe.AspNetCore.csproj | 4 +- .../Extensions/DebugProbeExtensions.cs | 59 ++++++---- .../Middleware/DebugProbeMiddleware.cs | 4 + .../Options/DebugProbeOptions.cs | 6 + DebugProbe.AspNetCore/README.md | 44 +++++++ README.md | 44 +++++++ SECURITY.md | 10 ++ 11 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs diff --git a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs index a2ad663..e7cbc14 100644 --- a/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs +++ b/DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs @@ -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); @@ -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"]; @@ -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); diff --git a/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs new file mode 100644 index 0000000..630a33c --- /dev/null +++ b/DebugProbe.AspNetCore.Tests/Extensions/DebugProbeAuthorizationEndpointTests.cs @@ -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( + 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 options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) + { + public const string SchemeName = "Test"; + public const string RoleHeaderName = "X-Test-Role"; + + protected override Task 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)); + } + } +} diff --git a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs index 6ee5702..1b6042e 100644 --- a/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs +++ b/DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs @@ -28,7 +28,10 @@ private DebugProbeWebApplication(WebApplication app) public static async Task CreateAsync( string environmentName, Action? mapEndpoints = null, - Action? configureOptions = null) + Action? configureOptions = null, + Action? configureServices = null, + Action? configureBeforeDebugProbe = null, + Action? configureUseDebugProbe = null) { var builder = WebApplication.CreateBuilder(new WebApplicationOptions { @@ -39,11 +42,13 @@ public static async Task 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); diff --git a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs index fff8bbd..9a72539 100644 --- a/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs +++ b/DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs @@ -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() { diff --git a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj index 7dd9bc7..f8e0b6b 100644 --- a/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj +++ b/DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj @@ -8,7 +8,7 @@ 1591 DebugProbe.AspNetCore - 1.6.2 + 1.6.3 Georgi Hristov @@ -17,7 +17,7 @@ aspnetcore;debugging;http;middleware;diagnostics;tracing;observability;api-debugging;developer-tools;trace-comparison;environment-comparison - 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 => ...) 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. icon.png diff --git a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs index d59496b..9bb5522 100644 --- a/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs +++ b/DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs @@ -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; @@ -63,10 +64,20 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services, /// Registers DebugProbe services. /// public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app) + { + return app.UseDebugProbe(configure: null); + } + + /// + /// Registers DebugProbe services and configures runtime endpoint options. + /// + public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app, Action? configure) { var options = app.ApplicationServices.GetRequiredService(); var environment = app.ApplicationServices.GetRequiredService(); + configure?.Invoke(options); + options.AllowLocalCompareTargets ??= environment.IsDevelopment(); app.UseMiddleware(); @@ -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) @@ -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); @@ -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)) { @@ -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)) { @@ -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) => { @@ -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); } @@ -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); + } + } } diff --git a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs index 215b485..d3265d5 100644 --- a/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs +++ b/DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs @@ -23,6 +23,10 @@ public class DebugProbeMiddleware "/debug", "/compare", "/swagger", + "/health", + "/healthz", + "/ready", + "/live", "/.well-known", // browser noise diff --git a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs index 0b8ee3d..ba5599f 100644 --- a/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs +++ b/DebugProbe.AspNetCore/Options/DebugProbeOptions.cs @@ -29,6 +29,12 @@ public class DebugProbeOptions /// public bool AllowUiInProduction { get; set; } + /// + /// Optional ASP.NET Core authorization policy required for DebugProbe endpoints. + /// When not configured, DebugProbe endpoints do not require authorization. + /// + public string? AuthorizationPolicy { get; set; } + /// /// Captures outgoing requests made through IHttpClientFactory. /// Defaults to true. diff --git a/DebugProbe.AspNetCore/README.md b/DebugProbe.AspNetCore/README.md index 9afdc77..bb15f31 100644 --- a/DebugProbe.AspNetCore/README.md +++ b/DebugProbe.AspNetCore/README.md @@ -34,6 +34,7 @@ http://localhost:{port}/debug ``` In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled. +DebugProbe does not require authentication by default. ## Optional Configuration @@ -79,6 +80,27 @@ builder.Services.AddDebugProbe(options => app.UseDebugProbe(); ``` +To protect DebugProbe endpoints, configure an ASP.NET Core authorization policy and require it when DebugProbe is registered: + +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("DebugProbePolicy", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole("Admin"); + }); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseDebugProbe(options => +{ + options.AuthorizationPolicy = "DebugProbePolicy"; +}); +``` + ## Features - Request inspection @@ -91,6 +113,7 @@ app.UseDebugProbe(); - Configurable body capture limits - Ignored path configuration for noisy or sensitive endpoints - Configurable redaction for sensitive headers, query parameters, and JSON fields +- Optional ASP.NET Core authorization policy protection for DebugProbe endpoints - Optional outgoing `HttpClient` request tracing ## Trace Compare @@ -119,6 +142,27 @@ builder.Services.AddDebugProbe(options => }); ``` +DebugProbe does not require authentication by default. If you expose DebugProbe outside local development, configure an ASP.NET Core authorization policy and require it for DebugProbe endpoints: + +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("DebugProbePolicy", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole("Admin"); + }); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseDebugProbe(options => +{ + options.AuthorizationPolicy = "DebugProbePolicy"; +}); +``` + DebugProbe masks common sensitive headers automatically: - `Authorization` diff --git a/README.md b/README.md index 6ec74e9..a9789db 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ http://localhost:{port}/debug ``` In Production, DebugProbe captures traces but does not register UI endpoints unless explicitly enabled. +DebugProbe does not require authentication by default. ## Optional Configuration @@ -79,6 +80,27 @@ builder.Services.AddDebugProbe(options => app.UseDebugProbe(); ``` +To protect DebugProbe endpoints, configure an ASP.NET Core authorization policy and require it when DebugProbe is registered: + +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("DebugProbePolicy", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole("Admin"); + }); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseDebugProbe(options => +{ + options.AuthorizationPolicy = "DebugProbePolicy"; +}); +``` + ## Features - Request inspection @@ -91,6 +113,7 @@ app.UseDebugProbe(); - Configurable body capture limits - Ignored path configuration for noisy or sensitive endpoints - Configurable redaction for sensitive headers, query parameters, and JSON fields +- Optional ASP.NET Core authorization policy protection for DebugProbe endpoints - Optional outgoing `HttpClient` request tracing ## Trace Compare @@ -119,6 +142,27 @@ builder.Services.AddDebugProbe(options => }); ``` +DebugProbe does not require authentication by default. If you expose DebugProbe outside local development, configure an ASP.NET Core authorization policy and require it for DebugProbe endpoints: + +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("DebugProbePolicy", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireRole("Admin"); + }); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.UseDebugProbe(options => +{ + options.AuthorizationPolicy = "DebugProbePolicy"; +}); +``` + DebugProbe masks common sensitive headers automatically: - `Authorization` diff --git a/SECURITY.md b/SECURITY.md index 2bd3707..b3723c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,6 +34,16 @@ By default: - Data is not persisted externally - Stored entries are cleared when the application stops - Only a limited number of requests are retained +- DebugProbe endpoints do not require authentication unless an authorization policy is configured + +When exposing DebugProbe outside local development, prefer protecting the endpoints with an ASP.NET Core authorization policy: + +```csharp +app.UseDebugProbe(options => +{ + options.AuthorizationPolicy = "DebugProbePolicy"; +}); +``` Avoid exposing DebugProbe endpoints publicly or using the package in production environments without proper security review and access restrictions.