Skip to content

Commit 8cda5c9

Browse files
authored
Add certificate allow list configuration (#5172)
1 parent 10ee979 commit 8cda5c9

File tree

7 files changed

+106
-7
lines changed

7 files changed

+106
-7
lines changed

src/Aspire.Dashboard/Configuration/DashboardOptions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ public sealed class ResourceServiceClientCertificateOptions
6161
public StoreLocation? Location { get; set; }
6262
}
6363

64+
public sealed class AllowedCertificateRule
65+
{
66+
public string? Thumbprint { get; set; }
67+
}
68+
6469
// Don't set values after validating/parsing options.
6570
public sealed class OtlpOptions
6671
{
@@ -76,6 +81,8 @@ public sealed class OtlpOptions
7681

7782
public string? HttpEndpointUrl { get; set; }
7883

84+
public List<AllowedCertificateRule> AllowedCertificates { get; set; } = new();
85+
7986
public Uri? GetGrpcEndpointUri()
8087
{
8188
return _parsedGrpcEndpointUrl;
@@ -169,7 +176,7 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
169176
{
170177
if (string.IsNullOrEmpty(EndpointUrls))
171178
{
172-
errorMessage = "One or more frontend endpoint URLs are not configured. Specify a Dashboard:Frontend:EndpointUrls value.";
179+
errorMessage = $"One or more frontend endpoint URLs are not configured. Specify an {DashboardConfigNames.DashboardFrontendUrlName.ConfigKey} value.";
173180
return false;
174181
}
175182
else

src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,18 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options)
7272
case OtlpAuthMode.ApiKey:
7373
if (string.IsNullOrEmpty(options.Otlp.PrimaryApiKey))
7474
{
75-
errorMessages.Add("PrimaryApiKey is required when OTLP authentication mode is API key. Specify a Dashboard:Otlp:PrimaryApiKey value.");
75+
errorMessages.Add($"PrimaryApiKey is required when OTLP authentication mode is API key. Specify a {DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.ConfigKey} value.");
7676
}
7777
break;
7878
case OtlpAuthMode.ClientCertificate:
79+
for (var i = 0; i < options.Otlp.AllowedCertificates.Count; i++)
80+
{
81+
var allowedCertRule = options.Otlp.AllowedCertificates[i];
82+
if (string.IsNullOrEmpty(allowedCertRule.Thumbprint))
83+
{
84+
errorMessages.Add($"Thumbprint on allow certificate rule is not configured. Specify a {DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:{i}:Thumbprint value.");
85+
}
86+
}
7987
break;
8088
case null:
8189
errorMessages.Add($"OTLP endpoint authentication is not configured. Either specify {DashboardConfigNames.DashboardUnsecuredAllowAnonymousName.ConfigKey}=true, or specify {DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey}. Possible values: {string.Join(", ", typeof(OtlpAuthMode).GetEnumNames())}");

src/Aspire.Dashboard/DashboardWebApplication.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,27 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb
564564
{
565565
OnCertificateValidated = context =>
566566
{
567+
var options = context.HttpContext.RequestServices.GetRequiredService<IOptions<DashboardOptions>>().Value;
568+
if (options.Otlp.AllowedCertificates is { Count: > 0 } allowList)
569+
{
570+
var allowed = false;
571+
foreach (var rule in allowList)
572+
{
573+
// Thumbprint is hexadecimal and is case-insensitive.
574+
if (string.Equals(rule.Thumbprint, context.ClientCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase))
575+
{
576+
allowed = true;
577+
break;
578+
}
579+
}
580+
581+
if (!allowed)
582+
{
583+
context.Fail("Certificate doesn't match allow list.");
584+
return Task.CompletedTask;
585+
}
586+
}
587+
567588
var claims = new[]
568589
{
569590
new Claim(ClaimTypes.NameIdentifier,

src/Shared/DashboardConfigNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal static class DashboardConfigNames
1717
public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY");
1818
public static readonly ConfigName DashboardOtlpCorsAllowedOriginsKeyName = new("Dashboard:Otlp:Cors:AllowedOrigins", "DASHBOARD__OTLP__CORS__ALLOWEDORIGINS");
1919
public static readonly ConfigName DashboardOtlpCorsAllowedHeadersKeyName = new("Dashboard:Otlp:Cors:AllowedHeaders", "DASHBOARD__OTLP__CORS__ALLOWEDHEADERS");
20+
public static readonly ConfigName DashboardOtlpAllowedCertificatesName = new("Dashboard:Otlp:AllowedCertificates", "DASHBOARD__OTLP__ALLOWEDCERTIFICATES");
2021
public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE");
2122
public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN");
2223
public static readonly ConfigName DashboardFrontendMaxConsoleLogCountName = new("Dashboard:Frontend:MaxConsoleLogCount", "DASHBOARD__FRONTEND__MAXCONSOLELOGCOUNT");

tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public void FrontendOptions_EmptyEndpointUrl()
4747
var result = new ValidateDashboardOptions().Validate(null, options);
4848

4949
Assert.False(result.Succeeded);
50-
Assert.Equal("One or more frontend endpoint URLs are not configured. Specify a Dashboard:Frontend:EndpointUrls value.", result.FailureMessage);
50+
Assert.Equal("One or more frontend endpoint URLs are not configured. Specify an ASPNETCORE_URLS value.", result.FailureMessage);
5151
}
5252

5353
[Fact]

tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,10 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateMissing_Fail
286286
Assert.True(ex.StatusCode is StatusCode.Unavailable or StatusCode.Internal, "gRPC call fails without cert.");
287287
}
288288

289-
[Fact]
290-
public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Success()
289+
[Theory]
290+
[InlineData(null)]
291+
[InlineData("91113E785A7100C246D4664420621157498BCC66")]
292+
public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Success(string? allowedThumbprint)
291293
{
292294
// Arrange
293295
X509Certificate2? clientCallbackCert = null;
@@ -299,6 +301,11 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Succes
299301

300302
config[DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = OtlpAuthMode.ClientCertificate.ToString();
301303

304+
if (allowedThumbprint != null)
305+
{
306+
config[$"{DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:0:Thumbprint"] = allowedThumbprint;
307+
}
308+
302309
config["Dashboard:Otlp:CertificateAuthOptions:AllowedCertificateTypes"] = "SelfSigned";
303310
config["Dashboard:Otlp:CertificateAuthOptions:ValidateValidityPeriod"] = "false";
304311
});
@@ -322,4 +329,43 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Succes
322329
// Assert
323330
Assert.Equal(0, response.PartialSuccess.RejectedLogRecords);
324331
}
332+
333+
[Fact]
334+
public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_NotInAllowedList_Failure()
335+
{
336+
// Arrange
337+
X509Certificate2? clientCallbackCert = null;
338+
339+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
340+
{
341+
// Change dashboard to HTTPS so the caller can negotiate a HTTP/2 connection.
342+
config[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = "https://127.0.0.1:0";
343+
344+
config[DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = OtlpAuthMode.ClientCertificate.ToString();
345+
346+
config[$"{DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:0:Thumbprint"] = "123";
347+
348+
config["Authentication:Schemes:Certificate:AllowedCertificateTypes"] = "SelfSigned";
349+
config["Authentication:Schemes:Certificate:ValidateValidityPeriod"] = "false";
350+
});
351+
await app.StartAsync();
352+
353+
var clientCertificates = new X509CertificateCollection(new[] { TestCertificateLoader.GetTestCertificate("eku.client.pfx") });
354+
using var channel = IntegrationTestHelpers.CreateGrpcChannel(
355+
$"https://{app.OtlpServiceGrpcEndPointAccessor().EndPoint}",
356+
_testOutputHelper,
357+
validationCallback: cert =>
358+
{
359+
clientCallbackCert = cert;
360+
},
361+
clientCertificates: clientCertificates);
362+
363+
var client = new LogsService.LogsServiceClient(channel);
364+
365+
// Act
366+
var ex = await Assert.ThrowsAsync<RpcException>(() => client.ExportAsync(new ExportLogsServiceRequest()).ResponseAsync);
367+
368+
// Assert
369+
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
370+
}
325371
}

tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,24 @@ public async Task Configuration_NoExtraConfig_Error()
4343

4444
// Assert
4545
Assert.Collection(app.ValidationFailures,
46-
s => s.Contains("Dashboard:Frontend:EndpointUrls"),
47-
s => s.Contains("Dashboard:Otlp:EndpointUrl"));
46+
s => Assert.Contains("ASPNETCORE_URLS", s),
47+
s => Assert.Contains("DOTNET_DASHBOARD_OTLP_ENDPOINT_URL", s));
48+
}
49+
50+
[Fact]
51+
public async Task Configuration_EmptyAllowedCertificateRule_Error()
52+
{
53+
// Arrange & Act
54+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper,
55+
additionalConfiguration: data =>
56+
{
57+
data["Dashboard:Otlp:AuthMode"] = nameof(OtlpAuthMode.ClientCertificate);
58+
data["Dashboard:Otlp:AllowedCertificates:0"] = string.Empty;
59+
});
60+
61+
// Assert
62+
Assert.Collection(app.ValidationFailures,
63+
s => Assert.Contains("Dashboard:Otlp:AllowedCertificates:0:Thumbprint", s));
4864
}
4965

5066
[Fact]

0 commit comments

Comments
 (0)