Asp.net core reject client: 403 on localhost but exception in Kubernetes cluster

6/11/2021

I'm developing this server hosted in a Kubernetes cluster which uses some basic certificate-based authentication. I have added the relevant parts of the server and client code but since this is quite a bit of lines I pasted it bellow with some explanatory text. The certificates used here were created based on this article.

The code seems to work when I run it on localhost via IIS Express however in a Linux based docker container I'm getting unexpected behavior.

When running the code locally the controller responds with 200 Ok if the client certificate is valid and a 403 Forbidden if I send no certificate or an invalid or untrusted one.

In the cluster the valid certificates work as well, however with the invalid or untrusted certificates I get the following exception:

Unhandled exception. System.Net.Http.HttpRequestException: An error occurred while sending the request.
 ---> System.IO.IOException: The response ended prematurely.
   at System.Net.Http.HttpConnection.FillAsync()
   at System.Net.Http.HttpConnection.ReadNextResponseHeaderLineAsync(Boolean foldedHeadersAllowed)
   at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
   at System.Net.Http.HttpConnection.SendAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.SendWithNtConnectionAuthAsync(HttpConnection connection, HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)
   at System.Net.Http.HttpConnectionPool.SendWithRetryAsync(HttpRequestMessage request, Boolean doRequestAuth, CancellationToken cancellationToken)
   at System.Net.Http.RedirectHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
   at System.Net.Http.HttpClient.FinishSendAsyncBuffered(Task`1 sendTask, HttpRequestMessage request, CancellationTokenSource cts, Boolean disposeCts)
   at AzureCertAuthClientConsole.Program.GetApiDataUsingHttpClientHandler(Boolean localhost, WhichCert whichCert) in C:\Users\user\Documents\CertAuthClientConsole\Program.cs:line 121

Line 121 here refers to the following code var response = await client.SendAsync(request); which you can see bellow.

I have enabled trace logging to get more insight in the server side of this error in which I can find this:

dbug: Microsoft.AspNetCore.Server.Kestrel[39]
      Connection id "0HM9CM5OJEA3K" accepted.
dbug: Microsoft.AspNetCore.Server.Kestrel[1]
      Connection id "0HM9CM5OJEA3K" started.
dbug: Microsoft.AspNetCore.Server.Kestrel.Https.Internal.HttpsConnectionMiddleware[1]
      Failed to authenticate HTTPS connection.
      System.Security.Authentication.AuthenticationException: The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
         at System.Net.Security.SslStream.SendAuthResetSignal(ProtocolToken message, ExceptionDispatchInfo exception)
         at System.Net.Security.SslStream.ForceAuthenticationAsync[TIOAdapter](TIOAdapter adapter, Boolean receiveFirst, Byte[] reAuthenticationData, Boolean isApm)
         at Microsoft.AspNetCore.Server.Kestrel.Https.Internal.HttpsConnectionMiddleware.OnConnectionAsync(ConnectionContext context)
dbug: Microsoft.AspNetCore.Server.Kestrel[2]
      Connection id "0HM9CM5OJEA3K" stopped.
dbug: Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets[7]
      Connection id "0HM9CM5OJEA3K" sending FIN because: "The Socket transport's send loop completed gracefully."

The code

Server side:

Relevant part of the Dockerfile

FROM base AS final
WORKDIR /app

COPY CARoot.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
COPY ServerSSL.pfx ./cert/

ENV Logging__LogLevel__Microsoft=Trace
ENV ASPNETCORE_Kestrel__Certificates__Default__Password=1234
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/app/cert/ServerSSL.pfx
ENV ASPNETCORE_URLS=https://+:443;http://+:80
ENV ASPNETCORE_ENVIRONMENT=Development

COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "AzureCertAuth.dll"]

As you can see I copy my root CA and the self-signed certificate to the image and update the ca certificates as to trust the my CA. Then I set some environment variables to configure Kestrel to use this server certifcate.

In Program.cs

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
            webBuilder.ConfigureKestrel(o =>
            {
                o.ConfigureHttpsDefaults(o =>
                {
                        //o.ServerCertificate = new X509Certificate2(@"C:\Users\user\Documents\cert\ServerSSL.pfx", "1234");  // For localhost                            
                    o.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
                });
            });

        });

In case I run it on localhost (IIS Express) I specify the server certificate here and in any case I require the client to send a certificate.

Startup.cs

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<MyCertificateValidationService>();

        services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
            .AddCertificate(o =>
            {
                o.AllowedCertificateTypes = CertificateTypes.All;
                o.RevocationMode = X509RevocationMode.NoCheck;
                o.ValidateCertificateUse = false;
                o.ValidateValidityPeriod = true;

                o.Events = new CertificateAuthenticationEvents
                {
                    OnCertificateValidated = context =>
                    {
                        var validationService = context.HttpContext.RequestServices.GetService<MyCertificateValidationService>();

                        if (validationService.ValidateCertificate(context.ClientCertificate))
                        {
                            var claims = new[]
                            {
                                new Claim(ClaimTypes.NameIdentifier, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer),
                                new Claim(ClaimTypes.Name, context.ClientCertificate.Subject, ClaimValueTypes.String, context.Options.ClaimsIssuer)
                            };

                            context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, context.Scheme.Name));
                            context.Success();
                        }
                        else
                        {
                            context.Fail("invalid cert");
                        }

                        return Task.CompletedTask;
                    },
                    OnAuthenticationFailed = context =>
                    {
                        context.Fail("authentication failed");
                        return Task.CompletedTask;
                    }
                };
            });

        services.AddControllers();
    }

Here the singleton MyCertificateValidationService validates that the thumbprint matches a list of thumbprints matching my client certs and if that fails or the default validation fails I fail the authentication context.

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        app.UseMiddleware(typeof(RequestLoggingMiddleware));

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers().RequireAuthorization();
        });
    }

Each controller requires authorization.

Client side:

I have several certificates all to test different aspects: Valid certificates, expired certificates, valid but signed by an untrusted CA. The selected certificate is set to the X509Certificate2 cert variable in the following snippet.

    HttpClientHandler handler = new HttpClientHandler();
    if (whichCert != WhichCert.None)
    {
        handler.ClientCertificates.Add(cert);
    }
    handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, error) => {
        Console.WriteLine(cert.Thumbprint);
        return true;
    };
        

    HttpClient client = new HttpClient(handler);

    Uri uri;
    if (localhost)
    {
        uri = new Uri("https://localhost:44361/WeatherForecast");
    }
    else
    {
        uri = new Uri($"https://{serverIp}:443/WeatherForecast");
    }

    HttpRequestMessage request = new HttpRequestMessage()
    {
        RequestUri = uri,
        Method = HttpMethod.Get,
    };

    var response = await client.SendAsync(request);  // Causes the exception
    if (response.IsSuccessStatusCode)
    {
        var responseContent = await response.Content.ReadAsStringAsync();
        Console.WriteLine(responseContent);
        var data = JsonDocument.Parse(responseContent);
        return data;
    }

    Console.WriteLine($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
    return;

To walk you through this snipper the first part is adding the client certificate to the HttpClientHandler and a custom validation callback that accepts any server certificate and prints the thumbprint just for me to verify that I have configure the server properly.

Then I create a HttpClient with a request to the default WeatherForecast controller and send and await the server's response.

-- Jurgy
asp.net-core
c#
docker
kubernetes
ssl-certificate

0 Answers