Skip to content

Commit 15eee60

Browse files
feat(security): add ASP.NET Core Authorization Policy Support for Debug Endpoints
Add support for protecting DebugProbe UI and API endpoints using ASP.NET Core authorization policies.
2 parents 2766565 + 7b48f1c commit 15eee60

11 files changed

Lines changed: 286 additions & 24 deletions

File tree

DebugProbe.AspNetCore.Tests/Configuration/DebugProbeOptionsTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public void Defaults_work_correctly()
1616
Assert.Equal(32, options.MaxBodyCaptureSizeKb);
1717
Assert.Null(options.AllowLocalCompareTargets);
1818
Assert.False(options.AllowUiInProduction);
19+
Assert.Null(options.AuthorizationPolicy);
1920
Assert.True(options.CaptureOutgoingHttpClientRequests);
2021
Assert.Empty(options.IgnorePaths);
2122
Assert.Equal(["Authorization", "Cookie", "Set-Cookie"], options.RedactedHeaders);
@@ -34,6 +35,7 @@ public void Custom_options_are_registered_and_used()
3435
options.MaxEntries = 2;
3536
options.MaxBodyCaptureSizeKb = 4;
3637
options.AllowLocalCompareTargets = true;
38+
options.AuthorizationPolicy = "DebugProbePolicy";
3739
options.IgnorePaths = ["/health"];
3840
options.CaptureOutgoingHttpClientRequests = false;
3941
options.RedactedHeaders = ["X-Api-Key"];
@@ -49,6 +51,7 @@ public void Custom_options_are_registered_and_used()
4951
Assert.Equal(2, options.MaxEntries);
5052
Assert.Equal(4, options.MaxBodyCaptureSizeKb);
5153
Assert.True(options.AllowLocalCompareTargets);
54+
Assert.Equal("DebugProbePolicy", options.AuthorizationPolicy);
5255
Assert.Equal(["/health"], options.IgnorePaths);
5356
Assert.False(options.CaptureOutgoingHttpClientRequests);
5457
Assert.Equal(["X-Api-Key"], options.RedactedHeaders);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Net;
2+
using System.Security.Claims;
3+
using System.Text.Encodings.Web;
4+
using DebugProbe.AspNetCore.Tests.Infrastructure;
5+
using Microsoft.AspNetCore.Authentication;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.Extensions.Hosting;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Options;
10+
11+
namespace DebugProbe.AspNetCore.Tests.Extensions;
12+
13+
public class DebugProbeAuthorizationEndpointTests
14+
{
15+
[Fact]
16+
public async Task Debug_endpoints_do_not_require_authorization_by_default()
17+
{
18+
await using var app = await DebugProbeWebApplication.CreateAsync(
19+
Environments.Development,
20+
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")));
21+
22+
await app.Client.GetAsync("/hello");
23+
24+
var debugResponse = await app.Client.GetAsync("/debug");
25+
var jsonResponse = await app.Client.GetAsync($"/debug/json/{app.SingleEntry.Id}");
26+
27+
Assert.Equal(HttpStatusCode.OK, debugResponse.StatusCode);
28+
Assert.Equal(HttpStatusCode.OK, jsonResponse.StatusCode);
29+
}
30+
31+
[Fact]
32+
public async Task Debug_endpoints_require_configured_authorization_policy()
33+
{
34+
await using var app = await DebugProbeWebApplication.CreateAsync(
35+
Environments.Development,
36+
endpoints => endpoints.MapGet("/hello", () => Results.Text("ok")),
37+
configureServices: services =>
38+
{
39+
services.AddAuthentication(TestAuthHandler.SchemeName)
40+
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
41+
TestAuthHandler.SchemeName,
42+
_ => { });
43+
44+
services.AddAuthorization(options =>
45+
{
46+
options.AddPolicy("DebugProbePolicy", policy =>
47+
{
48+
policy.RequireAuthenticatedUser();
49+
policy.RequireRole("Admin");
50+
});
51+
});
52+
},
53+
configureBeforeDebugProbe: app =>
54+
{
55+
app.UseAuthentication();
56+
app.UseAuthorization();
57+
},
58+
configureUseDebugProbe: options => options.AuthorizationPolicy = "DebugProbePolicy");
59+
60+
await app.Client.GetAsync("/hello");
61+
var traceId = app.SingleEntry.Id;
62+
63+
var unauthorizedDebugResponse = await app.Client.GetAsync("/debug");
64+
var unauthorizedJsonResponse = await app.Client.GetAsync($"/debug/json/{traceId}");
65+
66+
using var authorizedRequest = new HttpRequestMessage(HttpMethod.Get, "/debug");
67+
authorizedRequest.Headers.Add(TestAuthHandler.RoleHeaderName, "Admin");
68+
var authorizedDebugResponse = await app.Client.SendAsync(authorizedRequest);
69+
70+
using var authorizedJsonRequest = new HttpRequestMessage(HttpMethod.Get, $"/debug/json/{traceId}");
71+
authorizedJsonRequest.Headers.Add(TestAuthHandler.RoleHeaderName, "Admin");
72+
var authorizedJsonResponse = await app.Client.SendAsync(authorizedJsonRequest);
73+
74+
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedDebugResponse.StatusCode);
75+
Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedJsonResponse.StatusCode);
76+
Assert.Equal(HttpStatusCode.OK, authorizedDebugResponse.StatusCode);
77+
Assert.Equal(HttpStatusCode.OK, authorizedJsonResponse.StatusCode);
78+
}
79+
80+
private sealed class TestAuthHandler(
81+
IOptionsMonitor<AuthenticationSchemeOptions> options,
82+
ILoggerFactory logger,
83+
UrlEncoder encoder)
84+
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
85+
{
86+
public const string SchemeName = "Test";
87+
public const string RoleHeaderName = "X-Test-Role";
88+
89+
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
90+
{
91+
if (!Request.Headers.TryGetValue(RoleHeaderName, out var role))
92+
{
93+
return Task.FromResult(AuthenticateResult.NoResult());
94+
}
95+
96+
var claims = new[]
97+
{
98+
new Claim(ClaimTypes.Name, "Test User"),
99+
new Claim(ClaimTypes.Role, role.ToString())
100+
};
101+
102+
var identity = new ClaimsIdentity(claims, SchemeName);
103+
var principal = new ClaimsPrincipal(identity);
104+
var ticket = new AuthenticationTicket(principal, SchemeName);
105+
106+
return Task.FromResult(AuthenticateResult.Success(ticket));
107+
}
108+
}
109+
}

DebugProbe.AspNetCore.Tests/Infrastructure/DebugProbeWebApplication.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ private DebugProbeWebApplication(WebApplication app)
2828
public static async Task<DebugProbeWebApplication> CreateAsync(
2929
string environmentName,
3030
Action<IEndpointRouteBuilder>? mapEndpoints = null,
31-
Action<DebugProbeOptions>? configureOptions = null)
31+
Action<DebugProbeOptions>? configureOptions = null,
32+
Action<IServiceCollection>? configureServices = null,
33+
Action<WebApplication>? configureBeforeDebugProbe = null,
34+
Action<DebugProbeOptions>? configureUseDebugProbe = null)
3235
{
3336
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
3437
{
@@ -39,11 +42,13 @@ public static async Task<DebugProbeWebApplication> CreateAsync(
3942

4043
builder.Services.AddRouting();
4144
builder.Services.AddDebugProbe(configureOptions);
45+
configureServices?.Invoke(builder.Services);
4246

4347
var app = builder.Build();
4448

4549
app.UseRouting();
46-
app.UseDebugProbe();
50+
configureBeforeDebugProbe?.Invoke(app);
51+
app.UseDebugProbe(configureUseDebugProbe);
4752

4853
mapEndpoints?.Invoke(app);
4954

DebugProbe.AspNetCore.Tests/Middleware/MiddlewareExecutionFlowTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ public async Task Ignored_paths_are_skipped()
4242
Assert.Empty(app.Store.GetAll());
4343
}
4444

45+
[Theory]
46+
[InlineData("/health")]
47+
[InlineData("/healthz")]
48+
[InlineData("/ready")]
49+
[InlineData("/live")]
50+
public async Task Default_health_probe_paths_are_skipped(string path)
51+
{
52+
await using var app = await DebugProbeTestApp.CreateAsync(endpoints =>
53+
{
54+
endpoints.MapGet(path, () => Results.Ok());
55+
});
56+
57+
var response = await app.Client.GetAsync(path);
58+
59+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
60+
Assert.Empty(app.Store.GetAll());
61+
}
62+
4563
[Fact]
4664
public async Task Debug_paths_are_skipped()
4765
{

DebugProbe.AspNetCore/DebugProbe.AspNetCore.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<NoWarn>1591</NoWarn>
99

1010
<PackageId>DebugProbe.AspNetCore</PackageId>
11-
<Version>1.6.2</Version>
11+
<Version>1.6.3</Version>
1212

1313
<Authors>Georgi Hristov</Authors>
1414

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

1919
<PackageReleaseNotes>
20-
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.
20+
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.
2121
</PackageReleaseNotes>
2222

2323
<PackageIcon>icon.png</PackageIcon>

DebugProbe.AspNetCore/Extensions/DebugProbeExtensions.cs

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using DebugProbe.AspNetCore.Models;
99
using DebugProbe.AspNetCore.Options;
1010
using DebugProbe.AspNetCore.Storage;
11+
using Microsoft.AspNetCore.Authorization;
1112
using Microsoft.AspNetCore.Builder;
1213
using Microsoft.AspNetCore.Http;
1314
using Microsoft.Extensions.DependencyInjection;
@@ -63,10 +64,20 @@ public static IServiceCollection AddDebugProbe(this IServiceCollection services,
6364
/// Registers DebugProbe services.
6465
/// </summary>
6566
public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
67+
{
68+
return app.UseDebugProbe(configure: null);
69+
}
70+
71+
/// <summary>
72+
/// Registers DebugProbe services and configures runtime endpoint options.
73+
/// </summary>
74+
public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app, Action<DebugProbeOptions>? configure)
6675
{
6776
var options = app.ApplicationServices.GetRequiredService<DebugProbeOptions>();
6877
var environment = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
6978

79+
configure?.Invoke(options);
80+
7081
options.AllowLocalCompareTargets ??= environment.IsDevelopment();
7182

7283
app.UseMiddleware<DebugProbeMiddleware>();
@@ -76,7 +87,7 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
7687
{
7788
if (ShouldMapUiEndpoints(environment, options))
7889
{
79-
webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
90+
RequireDebugAuthorization(webApp.MapGet("/debug", async (HttpContext ctx, DebugEntryStore store) =>
8091
{
8192
var items = store.GetAll()
8293
.OrderByDescending(x => x.Timestamp)
@@ -87,9 +98,9 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
8798

8899
await ctx.Response.WriteAsync(html);
89100

90-
}).ExcludeFromDescription();
101+
}).ExcludeFromDescription(), options);
91102

92-
webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
103+
RequireDebugAuthorization(webApp.MapGet("/debug/{id}", async (HttpContext ctx, string id, DebugEntryStore store) =>
93104
{
94105
var item = store.Get(id);
95106

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

109120
await ctx.Response.WriteAsync(html);
110121

111-
}).ExcludeFromDescription();
122+
}).ExcludeFromDescription(), options);
112123

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

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

124-
}).ExcludeFromDescription();
135+
}).ExcludeFromDescription(), options);
125136

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

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

135-
}).ExcludeFromDescription();
146+
}).ExcludeFromDescription(), options);
136147

137-
webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
148+
RequireDebugAuthorization(webApp.MapPost("/debug/clear", (DebugEntryStore store) =>
138149
{
139150
store.Clear();
140151

141152
return Results.Ok();
142153

143-
}).ExcludeFromDescription();
154+
}).ExcludeFromDescription(), options);
144155

145-
webApp.Map("/debug/logo.png", ctx =>
156+
RequireDebugAuthorization(webApp.Map("/debug/logo.png", ctx =>
146157
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_logo_white_transparent.png", "image/png")
147-
).ExcludeFromDescription();
158+
).ExcludeFromDescription(), options);
148159

149-
webApp.Map("/debug/favicon.ico", ctx =>
160+
RequireDebugAuthorization(webApp.Map("/debug/favicon.ico", ctx =>
150161
EmbeddedAssetWriter.WriteEmbeddedAsset(ctx, "DebugProbe.AspNetCore.Assets.images.debugprobe_favicon.ico", "image/x-icon")
151-
).ExcludeFromDescription();
162+
).ExcludeFromDescription(), options);
152163
}
153164

154-
webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
165+
RequireDebugAuthorization(webApp.MapGet("/debug/compare/{id}", async (string id, string baseUrl, string remoteTraceId,
155166
DebugEntryStore store,
156167
DebugProbeOptions options) =>
157168
{
@@ -228,21 +239,21 @@ public static IApplicationBuilder UseDebugProbe(this IApplicationBuilder app)
228239
diffs = diff
229240
});
230241

231-
}).ExcludeFromDescription();
242+
}).ExcludeFromDescription(), options);
232243

233-
webApp.MapGet("/debug/environment", (DebugEntryStore store) =>
244+
RequireDebugAuthorization(webApp.MapGet("/debug/environment", (DebugEntryStore store) =>
234245
{
235246
return Results.Ok(store.Environment);
236247

237-
}).ExcludeFromDescription();
248+
}).ExcludeFromDescription(), options);
238249

239-
webApp.MapGet("/debug/json/{id}", (string id, DebugEntryStore store) =>
250+
RequireDebugAuthorization(webApp.MapGet("/debug/json/{id}", (string id, DebugEntryStore store) =>
240251
{
241252
var item = store.Get(id);
242253

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

245-
}).ExcludeFromDescription();
256+
}).ExcludeFromDescription(), options);
246257

247258

248259
}
@@ -254,4 +265,12 @@ private static bool ShouldMapUiEndpoints(IHostEnvironment environment, DebugProb
254265
{
255266
return !environment.IsProduction() || options.AllowUiInProduction;
256267
}
268+
269+
private static void RequireDebugAuthorization(IEndpointConventionBuilder endpoint, DebugProbeOptions options)
270+
{
271+
if (!string.IsNullOrWhiteSpace(options.AuthorizationPolicy))
272+
{
273+
endpoint.RequireAuthorization(options.AuthorizationPolicy);
274+
}
275+
}
257276
}

DebugProbe.AspNetCore/Middleware/DebugProbeMiddleware.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public class DebugProbeMiddleware
2323
"/debug",
2424
"/compare",
2525
"/swagger",
26+
"/health",
27+
"/healthz",
28+
"/ready",
29+
"/live",
2630
"/.well-known",
2731

2832
// browser noise

DebugProbe.AspNetCore/Options/DebugProbeOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ public class DebugProbeOptions
2929
/// </summary>
3030
public bool AllowUiInProduction { get; set; }
3131

32+
/// <summary>
33+
/// Optional ASP.NET Core authorization policy required for DebugProbe endpoints.
34+
/// When not configured, DebugProbe endpoints do not require authorization.
35+
/// </summary>
36+
public string? AuthorizationPolicy { get; set; }
37+
3238
/// <summary>
3339
/// Captures outgoing requests made through IHttpClientFactory.
3440
/// Defaults to true.

0 commit comments

Comments
 (0)