Skip to content

Commit

Permalink
Add certificate allow list configuration (#5172)
Browse files Browse the repository at this point in the history
  • Loading branch information
JamesNK authored Aug 7, 2024
1 parent 10ee979 commit 8cda5c9
Show file tree
Hide file tree
Showing 7 changed files with 106 additions and 7 deletions.
9 changes: 8 additions & 1 deletion src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -76,6 +81,8 @@ public sealed class OtlpOptions

public string? HttpEndpointUrl { get; set; }

public List<AllowedCertificateRule> AllowedCertificates { get; set; } = new();

public Uri? GetGrpcEndpointUri()
{
return _parsedGrpcEndpointUrl;
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())}");
Expand Down
21 changes: 21 additions & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,27 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb
{
OnCertificateValidated = context =>
{
var options = context.HttpContext.RequestServices.GetRequiredService<IOptions<DashboardOptions>>().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,
Expand Down
1 change: 1 addition & 0 deletions src/Shared/DashboardConfigNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
50 changes: 48 additions & 2 deletions tests/Aspire.Dashboard.Tests/Integration/OtlpGrpcServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
});
Expand All @@ -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<RpcException>(() => client.ExportAsync(new ExportLogsServiceRequest()).ResponseAsync);

// Assert
Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode);
}
}
20 changes: 18 additions & 2 deletions tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down

0 comments on commit 8cda5c9

Please sign in to comment.