KubernetesClient has a moderate security vulnerability
(Submitting this so that I can reference it in a PR that hopefully resolves it)
Please, fill the following sections to help us fix the issue
What happened: KuberetesClient was just confirmed to have a moderate security vulnerability. For projects that have vulnerability checks turned on, this will prevent them from building. Those projects could take a direct dependency on KubernetesClient, but if we can update it here, we might be able to prevent that need.
What you expected to happen:
How to reproduce it (as minimally and precisely as possible): Turn on nuget security auditing, build a project that includes AspNetCore.HealthChecks.UI
Source code sample:
Anything else we need to know?:
Environment:
- .NET Core version
- Healthchecks version
- Operative system:
- Others:
While the PR doesn't seem to get much attention, there is a workaround, if you see yourself blocked by the issue:
- Add an explicit dependency to latest KubernetesClient nuget package (e.g., 17.0.14) to fix the vulnerability.
- Re-implement your own IHealthCheckReportCollector, based on the original source code from here, but make sure to replace the now missing
BasicAuthenticationHeaderValuewithAuthenticationHeaderValue. - Overwrite the IHealthCheckReportCollector after the services.AddHealthChecksUI() call.
Example implementation (click to expand)
Registration:services.AddHealthChecksUI().AddInMemoryStorage();
services.AddScoped<IHealthCheckReportCollector, HealthCheckReportCollector>();
Implementation:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using HealthChecks.UI.Core;
using HealthChecks.UI.Core.Extensions;
using HealthChecks.UI.Core.HostedService;
using HealthChecks.UI.Core.Notifications;
using HealthChecks.UI.Data;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace MyProject.HealthChecks;
// This class replaces built-in HealthCheckReportCollector from HealthChecks.UI.Core.HostedService.
// Original class needs IdentityModel package for BasicAuthenticationHeaderValue, which is decommissioned and removed from nuget.
// The IdentityModel itself was coming from KubernetesClient 15.x transitive dependency, which we've bumped up to 17.0.14+.
// The new KubernetesClient 17.x version doesn't have IdentityModel dependency anymore, so we need this workaround.
// See https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/issues/2434 and linked PRs for more details.
internal sealed class HealthCheckReportCollector : IHealthCheckReportCollector, IDisposable
{
private readonly HealthChecksDb _db;
private readonly IHealthCheckFailureNotifier _healthCheckFailureNotifier;
private readonly HttpClient _httpClient;
private readonly ILogger<HealthCheckReportCollector> _logger;
private readonly ServerAddressesService _serverAddressService;
private readonly IEnumerable<IHealthCheckCollectorInterceptor> _interceptors;
private static readonly Dictionary<int, Uri> _endpointAddresses = new();
private static readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web)
{
Converters =
{
// allowIntegerValues: true https://github.com/Xabaril/AspNetCore.Diagnostics.HealthChecks/issues/1422
new JsonStringEnumConverter(namingPolicy: null, allowIntegerValues: true)
}
};
private bool _disposed;
public HealthCheckReportCollector(
HealthChecksDb db,
IHealthCheckFailureNotifier healthCheckFailureNotifier,
IHttpClientFactory httpClientFactory,
ILogger<HealthCheckReportCollector> logger,
IServer server,
IEnumerable<IHealthCheckCollectorInterceptor> interceptors)
{
_db = db;
_healthCheckFailureNotifier = healthCheckFailureNotifier;
_logger = logger;
_serverAddressService = new ServerAddressesService(server);
_interceptors = interceptors;
_httpClient = httpClientFactory.CreateClient("health-checks");
}
public async Task Collect(CancellationToken cancellationToken)
{
using (_logger.BeginScope("HealthReportCollector is collecting health checks results."))
{
var healthChecks = await _db.Configurations.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var item in healthChecks.OrderBy(h => h.Id))
{
if (cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("HealthReportCollector has been cancelled.");
break;
}
foreach (var interceptor in _interceptors)
{
await interceptor.OnCollectExecuting(item).ConfigureAwait(false);
}
var healthReport = await GetHealthReportAsync(item).ConfigureAwait(false);
if (healthReport.Status != UIHealthStatus.Healthy)
{
await _healthCheckFailureNotifier.NotifyDown(item.Name, healthReport).ConfigureAwait(false);
}
else if (await HasLivenessRecoveredFromFailureAsync(item).ConfigureAwait(false))
{
await _healthCheckFailureNotifier.NotifyWakeUp(item.Name).ConfigureAwait(false);
}
await SaveExecutionHistoryAsync(item, healthReport).ConfigureAwait(false);
foreach (var interceptor in _interceptors)
{
await interceptor.OnCollectExecuted(healthReport).ConfigureAwait(false);
}
}
_logger.LogDebug("HealthReportCollector has completed.");
}
}
public void Dispose()
{
if (_disposed)
return;
_httpClient.Dispose();
_disposed = true;
}
private async Task<UIHealthReport> GetHealthReportAsync(HealthCheckConfiguration configuration)
{
var (uri, name) = configuration;
try
{
var absoluteUri = GetEndpointUri(configuration);
HttpResponseMessage? response = null;
if (!string.IsNullOrEmpty(absoluteUri.UserInfo))
{
var userInfoArr = absoluteUri.UserInfo.Split(':');
if (userInfoArr.Length == 2 && !string.IsNullOrEmpty(userInfoArr[0]) && !string.IsNullOrEmpty(userInfoArr[1]))
{
//_httpClient.DefaultRequestHeaders.Authorization = new BasicAuthenticationHeaderValue(userInfoArr[0], userInfoArr[1]);
// To support basic auth; we can add an auth header to _httpClient, in the DefaultRequestHeaders (as above commented line).
// This would then be in place for the duration of the _httpClient lifetime, with the auth header present in every
// request. This also means every call to GetHealthReportAsync should check if _httpClient's DefaultRequestHeaders
// has already had auth added.
// Otherwise, if you don't want to effect _httpClient's DefaultRequestHeaders, then you have to explicitly create
// a request message (for each request) and add/set the auth header in each request message. Doing the latter
// means you can't use _httpClient.GetAsync and have to use _httpClient.SendAsync
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, absoluteUri);
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(
Convert.ToBase64String(
Encoding.UTF8.GetBytes(absoluteUri.UserInfo)));
response = await _httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
}
}
response ??= await _httpClient.GetAsync(absoluteUri, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
using (response)
{
if (!response.IsSuccessStatusCode && response.Content.Headers.ContentType?.MediaType != "application/json")
return UIHealthReport.CreateFrom(new InvalidOperationException($"HTTP response is not in valid state ({response.StatusCode}) when trying to get report from {uri} configured with name {name}."));
return await response.Content.ReadFromJsonAsync<UIHealthReport>(_options).ConfigureAwait(false)
?? throw new InvalidOperationException($"{nameof(HttpContentJsonExtensions.ReadFromJsonAsync)} returned null");
}
}
catch (Exception exception)
{
_logger.LogError(exception,
"GetHealthReport threw an exception when trying to get report from {Uri} configured with name {Name}.", uri, name);
return UIHealthReport.CreateFrom(exception);
}
}
private Uri GetEndpointUri(HealthCheckConfiguration configuration)
{
if (_endpointAddresses.TryGetValue(configuration.Id, out var uri))
return uri;
Uri.TryCreate(configuration.Uri, UriKind.Absolute, out var absoluteUri);
if (absoluteUri == null || !absoluteUri.IsValidHealthCheckEndpoint())
{
Uri.TryCreate(_serverAddressService.AbsoluteUriFromRelative(configuration.Uri), UriKind.Absolute, out absoluteUri);
}
if (absoluteUri == null)
throw new InvalidOperationException("Could not get endpoint uri from configuration");
_endpointAddresses[configuration.Id] = absoluteUri;
return absoluteUri;
}
private async Task<bool> HasLivenessRecoveredFromFailureAsync(HealthCheckConfiguration configuration)
{
var previous = await GetHealthCheckExecutionAsync(configuration).ConfigureAwait(false);
return previous != null && previous.Status != UIHealthStatus.Healthy;
}
private async Task<HealthCheckExecution?> GetHealthCheckExecutionAsync(HealthCheckConfiguration configuration) =>
await _db.Executions
.Include(le => le.History)
.Include(le => le.Entries)
.Where(le => le.Name == configuration.Name)
.SingleOrDefaultAsync()
.ConfigureAwait(false);
private async Task SaveExecutionHistoryAsync(HealthCheckConfiguration configuration, UIHealthReport healthReport)
{
_logger.LogDebug("HealthReportCollector - health report execution history saved.");
var execution = await GetHealthCheckExecutionAsync(configuration).ConfigureAwait(false);
var lastExecutionTime = DateTime.UtcNow;
if (execution != null)
{
if (execution.Uri != configuration.Uri)
{
UpdateUris(execution, configuration);
}
if (execution.Status == healthReport.Status)
{
_logger.LogDebug("HealthReport history already exists and is in the same state, updating the values.");
execution.LastExecuted = lastExecutionTime;
}
else
{
SaveExecutionHistoryEntries(healthReport, execution, lastExecutionTime);
}
// update existing entries with values from new health report
foreach (var item in healthReport.ToExecutionEntries())
{
var existing = execution.Entries
.SingleOrDefault(e => e.Name == item.Name);
if (existing != null)
{
existing.Status = item.Status;
existing.Description = item.Description;
existing.Duration = item.Duration;
existing.Tags = item.Tags;
}
else
{
execution.Entries.Add(item);
}
}
// remove old entries if existing execution not present in new health report
foreach (var item in execution.Entries)
{
if (!healthReport.Entries.ContainsKey(item.Name))
_db.HealthCheckExecutionEntries.Remove(item);
}
}
else
{
_logger.LogDebug("Creating a new HealthReport history.");
execution = new HealthCheckExecution
{
LastExecuted = lastExecutionTime,
OnStateFrom = lastExecutionTime,
Entries = healthReport.ToExecutionEntries(),
Status = healthReport.Status,
Name = configuration.Name,
Uri = configuration.Uri,
DiscoveryService = configuration.DiscoveryService
};
await _db.Executions
.AddAsync(execution)
.ConfigureAwait(false);
}
await _db.SaveChangesAsync().ConfigureAwait(false);
}
private static void UpdateUris(HealthCheckExecution execution, HealthCheckConfiguration configuration)
{
execution.Uri = configuration.Uri;
_endpointAddresses.Remove(configuration.Id);
}
private void SaveExecutionHistoryEntries(UIHealthReport healthReport, HealthCheckExecution execution, DateTime lastExecutionTime)
{
_logger.LogDebug("HealthCheckReportCollector already exists but on different state, updating the values.");
foreach (var item in execution.Entries)
{
// If the health service is down, no entry in dictionary
if (healthReport.Entries.TryGetValue(item.Name, out var reportEntry) &&
item.Status != reportEntry.Status)
{
execution.History.Add(new HealthCheckExecutionHistory
{
On = lastExecutionTime,
Status = reportEntry.Status,
Name = item.Name,
Description = reportEntry.Description
});
}
}
execution.OnStateFrom = lastExecutionTime;
execution.LastExecuted = lastExecutionTime;
execution.Status = healthReport.Status;
}
private sealed class ServerAddressesService
{
private readonly IServer _server;
public ServerAddressesService(IServer server) => _server = server;
private ICollection<string>? Addresses => AddressesFeature?.Addresses;
private IServerAddressesFeature? AddressesFeature =>
_server.Features.Get<IServerAddressesFeature>();
internal string AbsoluteUriFromRelative(string relativeUrl)
{
var targetAddress = Addresses!.First();
if (targetAddress.EndsWith('/'))
{
targetAddress = targetAddress[..^1];
}
if (!relativeUrl.StartsWith('/'))
{
relativeUrl = $"/{relativeUrl}";
}
return $"{targetAddress}{relativeUrl}";
}
}
}
Why is it referenced in UI at all? It must not be there!