diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index fce52fe260..612395fe79 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -61,6 +61,11 @@ public sealed class ResourceServiceClientCertificateOptions public StoreLocation? Location { get; set; } } +public sealed class AllowedCertificateRule +{ + public string? Thumbprint { get; set; } +} + // Don't set values after validating/parsing options. public sealed class OtlpOptions { @@ -76,6 +81,8 @@ public sealed class OtlpOptions public string? HttpEndpointUrl { get; set; } + public List AllowedCertificates { get; set; } = new(); + public Uri? GetGrpcEndpointUri() { return _parsedGrpcEndpointUrl; @@ -169,7 +176,7 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) { if (string.IsNullOrEmpty(EndpointUrls)) { - errorMessage = "One or more frontend endpoint URLs are not configured. Specify a Dashboard:Frontend:EndpointUrls value."; + errorMessage = $"One or more frontend endpoint URLs are not configured. Specify an {DashboardConfigNames.DashboardFrontendUrlName.ConfigKey} value."; return false; } else diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index 56cd909a5e..2ab97f3c60 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -72,10 +72,18 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) case OtlpAuthMode.ApiKey: if (string.IsNullOrEmpty(options.Otlp.PrimaryApiKey)) { - errorMessages.Add("PrimaryApiKey is required when OTLP authentication mode is API key. Specify a Dashboard:Otlp:PrimaryApiKey value."); + errorMessages.Add($"PrimaryApiKey is required when OTLP authentication mode is API key. Specify a {DashboardConfigNames.DashboardOtlpPrimaryApiKeyName.ConfigKey} value."); } break; case OtlpAuthMode.ClientCertificate: + for (var i = 0; i < options.Otlp.AllowedCertificates.Count; i++) + { + var allowedCertRule = options.Otlp.AllowedCertificates[i]; + if (string.IsNullOrEmpty(allowedCertRule.Thumbprint)) + { + errorMessages.Add($"Thumbprint on allow certificate rule is not configured. Specify a {DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:{i}:Thumbprint value."); + } + } break; case null: 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())}"); diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index e7f5027ca7..ab64f37f44 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -564,6 +564,27 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb { OnCertificateValidated = context => { + var options = context.HttpContext.RequestServices.GetRequiredService>().Value; + if (options.Otlp.AllowedCertificates is { Count: > 0 } allowList) + { + var allowed = false; + foreach (var rule in allowList) + { + // Thumbprint is hexadecimal and is case-insensitive. + if (string.Equals(rule.Thumbprint, context.ClientCertificate.Thumbprint, StringComparison.OrdinalIgnoreCase)) + { + allowed = true; + break; + } + } + + if (!allowed) + { + context.Fail("Certificate doesn't match allow list."); + return Task.CompletedTask; + } + } + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 6ba2a96044..b41127d584 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -17,6 +17,7 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY"); public static readonly ConfigName DashboardOtlpCorsAllowedOriginsKeyName = new("Dashboard:Otlp:Cors:AllowedOrigins", "DASHBOARD__OTLP__CORS__ALLOWEDORIGINS"); public static readonly ConfigName DashboardOtlpCorsAllowedHeadersKeyName = new("Dashboard:Otlp:Cors:AllowedHeaders", "DASHBOARD__OTLP__CORS__ALLOWEDHEADERS"); + public static readonly ConfigName DashboardOtlpAllowedCertificatesName = new("Dashboard:Otlp:AllowedCertificates", "DASHBOARD__OTLP__ALLOWEDCERTIFICATES"); public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE"); public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN"); public static readonly ConfigName DashboardFrontendMaxConsoleLogCountName = new("Dashboard:Frontend:MaxConsoleLogCount", "DASHBOARD__FRONTEND__MAXCONSOLELOGCOUNT"); diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index e256d7e3de..64666c3d07 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -47,7 +47,7 @@ public void FrontendOptions_EmptyEndpointUrl() var result = new ValidateDashboardOptions().Validate(null, options); Assert.False(result.Succeeded); - Assert.Equal("One or more frontend endpoint URLs are not configured. Specify a Dashboard:Frontend:EndpointUrls value.", result.FailureMessage); + Assert.Equal("One or more frontend endpoint URLs are not configured. Specify an ASPNETCORE_URLS value.", result.FailureMessage); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs index 624876e66d..680dbf6ead 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs @@ -286,8 +286,10 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateMissing_Fail Assert.True(ex.StatusCode is StatusCode.Unavailable or StatusCode.Internal, "gRPC call fails without cert."); } - [Fact] - public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Success() + [Theory] + [InlineData(null)] + [InlineData("91113E785A7100C246D4664420621157498BCC66")] + public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Success(string? allowedThumbprint) { // Arrange X509Certificate2? clientCallbackCert = null; @@ -299,6 +301,11 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Succes config[DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = OtlpAuthMode.ClientCertificate.ToString(); + if (allowedThumbprint != null) + { + config[$"{DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:0:Thumbprint"] = allowedThumbprint; + } + config["Dashboard:Otlp:CertificateAuthOptions:AllowedCertificateTypes"] = "SelfSigned"; config["Dashboard:Otlp:CertificateAuthOptions:ValidateValidityPeriod"] = "false"; }); @@ -322,4 +329,43 @@ public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_Succes // Assert Assert.Equal(0, response.PartialSuccess.RejectedLogRecords); } + + [Fact] + public async Task CallService_OtlpEndpoint_RequiredClientCertificateValid_NotInAllowedList_Failure() + { + // Arrange + X509Certificate2? clientCallbackCert = null; + + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + // Change dashboard to HTTPS so the caller can negotiate a HTTP/2 connection. + config[DashboardConfigNames.DashboardOtlpGrpcUrlName.ConfigKey] = "https://127.0.0.1:0"; + + config[DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = OtlpAuthMode.ClientCertificate.ToString(); + + config[$"{DashboardConfigNames.DashboardOtlpAllowedCertificatesName.ConfigKey}:0:Thumbprint"] = "123"; + + config["Authentication:Schemes:Certificate:AllowedCertificateTypes"] = "SelfSigned"; + config["Authentication:Schemes:Certificate:ValidateValidityPeriod"] = "false"; + }); + await app.StartAsync(); + + var clientCertificates = new X509CertificateCollection(new[] { TestCertificateLoader.GetTestCertificate("eku.client.pfx") }); + using var channel = IntegrationTestHelpers.CreateGrpcChannel( + $"https://{app.OtlpServiceGrpcEndPointAccessor().EndPoint}", + _testOutputHelper, + validationCallback: cert => + { + clientCallbackCert = cert; + }, + clientCertificates: clientCertificates); + + var client = new LogsService.LogsServiceClient(channel); + + // Act + var ex = await Assert.ThrowsAsync(() => client.ExportAsync(new ExportLogsServiceRequest()).ResponseAsync); + + // Assert + Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); + } } diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index cb2d767118..4cecea21a9 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -43,8 +43,24 @@ public async Task Configuration_NoExtraConfig_Error() // Assert Assert.Collection(app.ValidationFailures, - s => s.Contains("Dashboard:Frontend:EndpointUrls"), - s => s.Contains("Dashboard:Otlp:EndpointUrl")); + s => Assert.Contains("ASPNETCORE_URLS", s), + s => Assert.Contains("DOTNET_DASHBOARD_OTLP_ENDPOINT_URL", s)); + } + + [Fact] + public async Task Configuration_EmptyAllowedCertificateRule_Error() + { + // Arrange & Act + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: data => + { + data["Dashboard:Otlp:AuthMode"] = nameof(OtlpAuthMode.ClientCertificate); + data["Dashboard:Otlp:AllowedCertificates:0"] = string.Empty; + }); + + // Assert + Assert.Collection(app.ValidationFailures, + s => Assert.Contains("Dashboard:Otlp:AllowedCertificates:0:Thumbprint", s)); } [Fact]