AspNetCore.Diagnostics.HealthChecks icon indicating copy to clipboard operation
AspNetCore.Diagnostics.HealthChecks copied to clipboard

KubernetesClient has a moderate security vulnerability

Open SeanKilleen opened this issue 4 months ago • 2 comments

(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:

SeanKilleen avatar Sep 18 '25 02:09 SeanKilleen

While the PR doesn't seem to get much attention, there is a workaround, if you see yourself blocked by the issue:

  1. Add an explicit dependency to latest KubernetesClient nuget package (e.g., 17.0.14) to fix the vulnerability.
  2. Re-implement your own IHealthCheckReportCollector, based on the original source code from here, but make sure to replace the now missing BasicAuthenticationHeaderValue with AuthenticationHeaderValue.
  3. 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}";
        }
    }
}

gleb-osokin-gen avatar Nov 27 '25 13:11 gleb-osokin-gen

Why is it referenced in UI at all? It must not be there!

xperiandri avatar Dec 01 '25 14:12 xperiandri