From 42dd104e5c5500aa602eec5898e61e41898ca2de Mon Sep 17 00:00:00 2001 From: Dhawal Seth Date: Mon, 18 May 2026 23:45:30 -0700 Subject: [PATCH 1/4] Add mTLS client certificate support for proxy authentication Add support for configuring TLS client certificates when connecting through proxies that require mTLS authentication. This is configured via environment variables: - HTTPS_PROXY_CLIENT_CERT: Path to client certificate file (PEM) - HTTPS_PROXY_CLIENT_KEY: Path to client private key file (PEM) - HTTPS_PROXY_CA_CERT: Path to CA certificate file (PEM) The HttpClientHandlerFactory loads these certificates and configures the HttpClientHandler to present them during TLS handshake. Co-Authored-By: Claude Opus 4.5 --- src/Runner.Common/HttpClientHandlerFactory.cs | 55 +++++++++++++++++++ src/Runner.Sdk/RunnerWebProxy.cs | 37 +++++++++++++ src/Test/L0/RunnerWebProxyL0.cs | 52 ++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/src/Runner.Common/HttpClientHandlerFactory.cs b/src/Runner.Common/HttpClientHandlerFactory.cs index 4e0a88db718..badadb9690d 100644 --- a/src/Runner.Common/HttpClientHandlerFactory.cs +++ b/src/Runner.Common/HttpClientHandlerFactory.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using GitHub.Runner.Sdk; namespace GitHub.Runner.Common @@ -21,7 +23,60 @@ public HttpClientHandler CreateClientHandler(RunnerWebProxy webProxy) client.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } + // Configure mTLS client certificate for proxy authentication + if (!string.IsNullOrEmpty(webProxy.HttpsProxyClientCert)) + { + var clientCert = LoadClientCertificate( + webProxy.HttpsProxyClientCert, + webProxy.HttpsProxyClientKey); + if (clientCert != null) + { + client.ClientCertificates.Add(clientCert); + } + } + return client; } + + private X509Certificate2 LoadClientCertificate(string certPath, string keyPath) + { + try + { + if (!File.Exists(certPath)) + { + Trace.Warning($"Client certificate file not found: {certPath}"); + return null; + } + + // If key path is provided separately, load cert and key from separate files + if (!string.IsNullOrEmpty(keyPath)) + { + if (!File.Exists(keyPath)) + { + Trace.Warning($"Client key file not found: {keyPath}"); + return null; + } + + // Load certificate and private key from separate PEM files + var certPem = File.ReadAllText(certPath); + var keyPem = File.ReadAllText(keyPath); + var cert = X509Certificate2.CreateFromPem(certPem, keyPem); + + // On Windows, we need to export and re-import to make the certificate usable + // with SslStream/HttpClient + return new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + // Assume the cert file contains both certificate and key (PFX/PKCS12 format) + return new X509Certificate2(certPath); + } + } + catch (Exception ex) + { + Trace.Warning($"Failed to load client certificate: {ex.Message}"); + return null; + } + } } } diff --git a/src/Runner.Sdk/RunnerWebProxy.cs b/src/Runner.Sdk/RunnerWebProxy.cs index 4c3c92a5502..a0505daaefd 100644 --- a/src/Runner.Sdk/RunnerWebProxy.cs +++ b/src/Runner.Sdk/RunnerWebProxy.cs @@ -22,6 +22,9 @@ public class RunnerWebProxy : IWebProxy private string _httpsProxyUsername; private string _httpsProxyPassword; private string _noProxyString; + private string _httpsProxyClientCert; + private string _httpsProxyClientKey; + private string _httpsProxyCACert; private readonly List _noProxyList = new(); private readonly HashSet _noProxyUnique = new(StringComparer.OrdinalIgnoreCase); @@ -35,6 +38,9 @@ public class RunnerWebProxy : IWebProxy public string HttpsProxyUsername => _httpsProxyUsername; public string HttpsProxyPassword => _httpsProxyPassword; public string NoProxyString => _noProxyString; + public string HttpsProxyClientCert => _httpsProxyClientCert; + public string HttpsProxyClientKey => _httpsProxyClientKey; + public string HttpsProxyCACert => _httpsProxyCACert; public List NoProxyList => _noProxyList; @@ -137,6 +143,37 @@ public RunnerWebProxy() } } + // Load mTLS client certificate configuration for proxy connections + var httpsProxyClientCert = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT"); + if (string.IsNullOrEmpty(httpsProxyClientCert)) + { + httpsProxyClientCert = Environment.GetEnvironmentVariable("https_proxy_client_cert"); + } + if (!string.IsNullOrEmpty(httpsProxyClientCert)) + { + _httpsProxyClientCert = httpsProxyClientCert.Trim(); + } + + var httpsProxyClientKey = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY"); + if (string.IsNullOrEmpty(httpsProxyClientKey)) + { + httpsProxyClientKey = Environment.GetEnvironmentVariable("https_proxy_client_key"); + } + if (!string.IsNullOrEmpty(httpsProxyClientKey)) + { + _httpsProxyClientKey = httpsProxyClientKey.Trim(); + } + + var httpsProxyCACert = Environment.GetEnvironmentVariable("HTTPS_PROXY_CA_CERT"); + if (string.IsNullOrEmpty(httpsProxyCACert)) + { + httpsProxyCACert = Environment.GetEnvironmentVariable("https_proxy_ca_cert"); + } + if (!string.IsNullOrEmpty(httpsProxyCACert)) + { + _httpsProxyCACert = httpsProxyCACert.Trim(); + } + if (!string.IsNullOrEmpty(noProxyList)) { _noProxyString = noProxyList; diff --git a/src/Test/L0/RunnerWebProxyL0.cs b/src/Test/L0/RunnerWebProxyL0.cs index 5e339e0a32f..1f9eca70799 100644 --- a/src/Test/L0/RunnerWebProxyL0.cs +++ b/src/Test/L0/RunnerWebProxyL0.cs @@ -581,6 +581,52 @@ public void WebProxyFromEnvironmentVariablesWithPort80() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void WebProxyClientCertificateFromEnvironmentVariables() + { + try + { + Environment.SetEnvironmentVariable("https_proxy", "http://127.0.0.1:9999"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY", "/path/to/client.key"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CA_CERT", "/path/to/ca.crt"); + var proxy = new RunnerWebProxy(); + + Assert.Equal("/path/to/client.crt", proxy.HttpsProxyClientCert); + Assert.Equal("/path/to/client.key", proxy.HttpsProxyClientKey); + Assert.Equal("/path/to/ca.crt", proxy.HttpsProxyCACert); + } + finally + { + CleanProxyEnv(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void WebProxyClientCertificateLowerCaseFromEnvironmentVariables() + { + try + { + Environment.SetEnvironmentVariable("https_proxy", "http://127.0.0.1:9999"); + Environment.SetEnvironmentVariable("https_proxy_client_cert", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("https_proxy_client_key", "/path/to/client.key"); + Environment.SetEnvironmentVariable("https_proxy_ca_cert", "/path/to/ca.crt"); + var proxy = new RunnerWebProxy(); + + Assert.Equal("/path/to/client.crt", proxy.HttpsProxyClientCert); + Assert.Equal("/path/to/client.key", proxy.HttpsProxyClientKey); + Assert.Equal("/path/to/ca.crt", proxy.HttpsProxyCACert); + } + finally + { + CleanProxyEnv(); + } + } + private void CleanProxyEnv() { Environment.SetEnvironmentVariable("http_proxy", null); @@ -589,6 +635,12 @@ private void CleanProxyEnv() Environment.SetEnvironmentVariable("HTTPS_PROXY", null); Environment.SetEnvironmentVariable("no_proxy", null); Environment.SetEnvironmentVariable("NO_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CA_CERT", null); + Environment.SetEnvironmentVariable("https_proxy_client_cert", null); + Environment.SetEnvironmentVariable("https_proxy_client_key", null); + Environment.SetEnvironmentVariable("https_proxy_ca_cert", null); } } } From 72b506979de050a093278f63bf575b72481913a6 Mon Sep 17 00:00:00 2001 From: Dhawal Seth Date: Fri, 29 May 2026 09:29:25 -0700 Subject: [PATCH 2/4] fix: wire mTLS client certs into VssHttpMessageHandler and RawHttpMessageHandler The runner's primary HTTP paths (VssConnection/RawConnection) create bare HttpClientHandler instances without loading client certificates. RunnerWebProxy reads HTTPS_PROXY_CLIENT_CERT/KEY env vars but only HttpClientHandlerFactory (secondary path) wires them into the handler. Changes: - VssHttpMessageHandler: add ConfigureClientCertificates callback, invoke it in ApplySettings after proxy is set - RawHttpMessageHandler: same callback pattern - VssUtil: set RawHttpMessageHandler.DefaultWebProxy (was missing), wire cert loading callback into both handlers This enables mTLS proxy authentication for all runner communication: job pickup, token refresh, broker connections, action downloads. Co-Authored-By: Claude Opus 4.5 --- src/Runner.Sdk/Util/VssUtil.cs | 34 +++++++++++++++++++ .../Common/Common/RawHttpMessageHandler.cs | 4 +++ .../Common/Common/VssHttpMessageHandler.cs | 4 +++ 3 files changed, 42 insertions(+) diff --git a/src/Runner.Sdk/Util/VssUtil.cs b/src/Runner.Sdk/Util/VssUtil.cs index 012d27f7345..84a3f5e3683 100644 --- a/src/Runner.Sdk/Util/VssUtil.cs +++ b/src/Runner.Sdk/Util/VssUtil.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using GitHub.DistributedTask.WebApi; using GitHub.Services.Common; using GitHub.Services.WebApi; @@ -34,6 +36,38 @@ public static void InitializeVssClientSettings(List addi VssClientHttpRequestSettings.Default.UserAgent = headerValues; VssHttpMessageHandler.DefaultWebProxy = proxy; + RawHttpMessageHandler.DefaultWebProxy = proxy; + + // Wire mTLS client cert loading into both HTTP message handlers + var certPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT") + ?? Environment.GetEnvironmentVariable("https_proxy_client_cert"); + var keyPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY") + ?? Environment.GetEnvironmentVariable("https_proxy_client_key"); + if (!string.IsNullOrEmpty(certPath) && File.Exists(certPath)) + { + Action configureCerts = (HttpClientHandler handler) => + { + try + { + var certPem = File.ReadAllText(certPath); + var keyPem = !string.IsNullOrEmpty(keyPath) && File.Exists(keyPath) ? File.ReadAllText(keyPath) : null; + X509Certificate2 cert; + if (keyPem != null) + { + cert = X509Certificate2.CreateFromPem(certPem, keyPem); + cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + cert = new X509Certificate2(certPath); + } + handler.ClientCertificates.Add(cert); + } + catch { } + }; + VssHttpMessageHandler.ConfigureClientCertificates = configureCerts; + RawHttpMessageHandler.ConfigureClientCertificates = configureCerts; + } if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_TLS_NO_VERIFY"))) { diff --git a/src/Sdk/Common/Common/RawHttpMessageHandler.cs b/src/Sdk/Common/Common/RawHttpMessageHandler.cs index e80e6a74727..1bef8ee7f6f 100644 --- a/src/Sdk/Common/Common/RawHttpMessageHandler.cs +++ b/src/Sdk/Common/Common/RawHttpMessageHandler.cs @@ -77,6 +77,8 @@ public RawClientHttpRequestSettings Settings // This needs to be investigated further. private static IWebProxy s_defaultWebProxy = null; + public static Action ConfigureClientCertificates { get; set; } + /// /// Allows you to set a proxy to be used by all RawHttpMessageHandler requests without affecting the global WebRequest.DefaultWebProxy. If not set it returns the WebRequest.DefaultWebProxy. /// @@ -300,6 +302,8 @@ private static void ApplySettings( httpClientHandler.Proxy = DefaultWebProxy; httpClientHandler.UseCookies = false; httpClientHandler.UseProxy = true; + + ConfigureClientCertificates?.Invoke(httpClientHandler); } } diff --git a/src/Sdk/Common/Common/VssHttpMessageHandler.cs b/src/Sdk/Common/Common/VssHttpMessageHandler.cs index f48eec41af8..46178dfa029 100644 --- a/src/Sdk/Common/Common/VssHttpMessageHandler.cs +++ b/src/Sdk/Common/Common/VssHttpMessageHandler.cs @@ -503,9 +503,13 @@ private static void ApplySettings( { httpClientHandler.AutomaticDecompression = DecompressionMethods.GZip; } + + ConfigureClientCertificates?.Invoke(httpClientHandler); } } + public static Action ConfigureClientCertificates { get; set; } + // setting this to WebRequest.DefaultWebProxy in NETSTANDARD is causing a System.PlatformNotSupportedException //.in System.Net.SystemWebProxy.IsBypassed. Comment in IsBypassed method indicates ".NET Core and .NET Native // code will handle this exception and call into WinInet/WinHttp as appropriate to use the system proxy." From 559c3a50d026a624eef3bf0732b5fdadb03ce5d2 Mon Sep 17 00:00:00 2001 From: Dhawal Seth Date: Fri, 29 May 2026 10:42:10 -0700 Subject: [PATCH 3/4] fix: add mTLS cert loading to VssOAuthTokenHttpClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth token HTTP client creates its own HttpClientHandler without loading client certificates. This breaks mTLS proxy auth for broker session creation (IssuedTokenProvider → RawHttpMessageHandler path). Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 +- src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 411fe4011a5..c57db802d21 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ TestResults TestLogs .DS_Store .mono -**/*.DotSettings.user \ No newline at end of file +**/*.DotSettings.useractions-runner-linux-x64-*.tar.gz diff --git a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs index 994675e41e0..d0b61bb93df 100644 --- a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs @@ -124,6 +124,8 @@ private static HttpMessageHandler CreateMessageHandler(Uri requestUri) messageHandler.UseProxy = true; } + VssHttpMessageHandler.ConfigureClientCertificates?.Invoke(messageHandler); + if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && VssClientHttpRequestSettings.Default.ClientCertificateManager != null && VssClientHttpRequestSettings.Default.ClientCertificateManager.ClientCertificates != null && From f08479f544758cdde96463375f4d2a45102ec131 Mon Sep 17 00:00:00 2001 From: Dhawal Seth Date: Fri, 29 May 2026 15:47:13 -0700 Subject: [PATCH 4/4] fix: add mTLS client cert support to Azure SDK blob transport Configure HttpClientTransport with client certificates for BlobClient and AppendBlobClient to support mTLS proxy authentication when uploading logs/results to Azure blob storage. Co-Authored-By: Claude Opus 4.5 --- src/Sdk/WebApi/WebApi/ResultsHttpClient.cs | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs index 31819a4b2bf..5f692be244e 100644 --- a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs @@ -5,10 +5,12 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using System.Net.Http.Formatting; using Azure; +using Azure.Core.Pipeline; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; @@ -191,6 +193,42 @@ private async Task JobLogUploadCompleteAsync(string planId, string jobId, long l return (blobUri.Uri, sasUrl); } + private HttpClientTransport GetMTLSTransport() + { + var certPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT") + ?? Environment.GetEnvironmentVariable("https_proxy_client_cert"); + var keyPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY") + ?? Environment.GetEnvironmentVariable("https_proxy_client_key"); + + if (string.IsNullOrEmpty(certPath) || !File.Exists(certPath)) + { + return null; + } + + try + { + var handler = new HttpClientHandler(); + var certPem = File.ReadAllText(certPath); + var keyPem = !string.IsNullOrEmpty(keyPath) && File.Exists(keyPath) ? File.ReadAllText(keyPath) : null; + X509Certificate2 cert; + if (keyPem != null) + { + cert = X509Certificate2.CreateFromPem(certPem, keyPem); + cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + cert = new X509Certificate2(certPath); + } + handler.ClientCertificates.Add(cert); + return new HttpClientTransport(handler); + } + catch + { + return null; + } + } + private BlobClient GetBlobClient(string url) { var blobUri = ParseSasToken(url); @@ -204,6 +242,12 @@ private BlobClient GetBlobClient(string url) } }; + var transport = GetMTLSTransport(); + if (transport != null) + { + opts.Transport = transport; + } + return new BlobClient(blobUri.path, new AzureSasCredential(blobUri.sas), opts); } @@ -220,6 +264,12 @@ private AppendBlobClient GetAppendBlobClient(string url) } }; + var transport = GetMTLSTransport(); + if (transport != null) + { + opts.Transport = transport; + } + return new AppendBlobClient(blobUri.path, new AzureSasCredential(blobUri.sas), opts); }