Skip to content

Commit b37503c

Browse files
authored
feat: add handshake Exception on ITlsHandshakeFeature (#65807)
1 parent d823ec3 commit b37503c

6 files changed

Lines changed: 212 additions & 12 deletions

File tree

src/Servers/Connections.Abstractions/src/Features/ITlsHandshakeFeature.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,12 @@ public interface ITlsHandshakeFeature
8282
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
8383
#endif
8484
int KeyExchangeStrength { get; }
85+
86+
#if NET11_0_OR_GREATER
87+
/// <summary>
88+
/// Gets the exception that occurred during the TLS handshake, if any.
89+
/// <see langword="null"/> if the handshake succeeded or has not yet completed.
90+
/// </summary>
91+
Exception? Exception => null;
92+
#endif
8593
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.Exception.get -> System.Exception?

src/Servers/Kestrel/Core/src/Internal/TlsConnectionFeature.cs

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,23 @@ internal sealed class TlsConnectionFeature : ITlsConnectionFeature, ITlsApplicat
1616
{
1717
private readonly SslStream _sslStream;
1818
private readonly ConnectionContext _context;
19+
private bool _snapshotted;
20+
1921
private X509Certificate2? _clientCert;
2022
private Task<X509Certificate2?>? _clientCertTask;
2123

24+
private SslProtocols _protocol;
25+
private TlsCipherSuite? _negotiatedCipherSuite;
26+
private ReadOnlyMemory<byte> _applicationProtocol;
27+
#pragma warning disable SYSLIB0058 // Obsolete TLS cipher algorithm enums
28+
private CipherAlgorithmType _cipherAlgorithm;
29+
private int _cipherStrength;
30+
private HashAlgorithmType _hashAlgorithm;
31+
private int _hashStrength;
32+
private ExchangeAlgorithmType _keyExchangeAlgorithm;
33+
private int _keyExchangeStrength;
34+
#pragma warning restore SYSLIB0058
35+
2236
public TlsConnectionFeature(SslStream sslStream, ConnectionContext context)
2337
{
2438
ArgumentNullException.ThrowIfNull(sslStream);
@@ -28,6 +42,47 @@ public TlsConnectionFeature(SslStream sslStream, ConnectionContext context)
2842
_context = context;
2943
}
3044

45+
/// <summary>
46+
/// Captures all SslStream-backed property values so they remain accessible after the SslStream is disposed.
47+
/// Must be called before disposing the SslStream.
48+
/// </summary>
49+
internal void Snapshot()
50+
{
51+
if (_snapshotted)
52+
{
53+
return;
54+
}
55+
_snapshotted = true;
56+
57+
if (_sslStream is null)
58+
{
59+
return;
60+
}
61+
62+
try
63+
{
64+
_protocol = _sslStream.SslProtocol;
65+
_negotiatedCipherSuite = _sslStream.NegotiatedCipherSuite;
66+
_applicationProtocol = _sslStream.NegotiatedApplicationProtocol.Protocol.ToArray();
67+
68+
#pragma warning disable SYSLIB0058 // Obsolete TLS cipher algorithm enums
69+
_cipherAlgorithm = _sslStream.CipherAlgorithm;
70+
_cipherStrength = _sslStream.CipherStrength;
71+
_hashAlgorithm = _sslStream.HashAlgorithm;
72+
_hashStrength = _sslStream.HashStrength;
73+
_keyExchangeAlgorithm = _sslStream.KeyExchangeAlgorithm;
74+
_keyExchangeStrength = _sslStream.KeyExchangeStrength;
75+
#pragma warning restore SYSLIB0058
76+
77+
_clientCert ??= ConvertToX509Certificate2(_sslStream.RemoteCertificate);
78+
}
79+
catch
80+
{
81+
// If the handshake never completed, SslStream properties may throw.
82+
// The snapshotted fields will retain their default values.
83+
}
84+
}
85+
3186
internal bool AllowDelayedClientCertificateNegotation { get; set; }
3287

3388
public X509Certificate2? ClientCertificate
@@ -45,33 +100,35 @@ public X509Certificate2? ClientCertificate
45100

46101
public string HostName { get; set; } = string.Empty;
47102

48-
public ReadOnlyMemory<byte> ApplicationProtocol => _sslStream.NegotiatedApplicationProtocol.Protocol;
103+
public ReadOnlyMemory<byte> ApplicationProtocol => _snapshotted ? _applicationProtocol : _sslStream.NegotiatedApplicationProtocol.Protocol;
49104

50-
public SslProtocols Protocol => _sslStream.SslProtocol;
105+
public SslProtocols Protocol => _snapshotted ? _protocol : _sslStream.SslProtocol;
51106

52107
public SslStream SslStream => _sslStream;
53108

54-
// We don't store the values for these because they could be changed by a renegotiation.
109+
public Exception? Exception { get; set; }
110+
111+
// After Snapshot() is called, all values are served from cached fields instead of the SslStream.
55112

56-
public TlsCipherSuite? NegotiatedCipherSuite => _sslStream.NegotiatedCipherSuite;
113+
public TlsCipherSuite? NegotiatedCipherSuite => _snapshotted ? _negotiatedCipherSuite : _sslStream.NegotiatedCipherSuite;
57114

58115
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
59-
public CipherAlgorithmType CipherAlgorithm => _sslStream.CipherAlgorithm;
116+
public CipherAlgorithmType CipherAlgorithm => _snapshotted ? _cipherAlgorithm : _sslStream.CipherAlgorithm;
60117

61118
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
62-
public int CipherStrength => _sslStream.CipherStrength;
119+
public int CipherStrength => _snapshotted ? _cipherStrength : _sslStream.CipherStrength;
63120

64121
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
65-
public HashAlgorithmType HashAlgorithm => _sslStream.HashAlgorithm;
122+
public HashAlgorithmType HashAlgorithm => _snapshotted ? _hashAlgorithm : _sslStream.HashAlgorithm;
66123

67124
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
68-
public int HashStrength => _sslStream.HashStrength;
125+
public int HashStrength => _snapshotted ? _hashStrength : _sslStream.HashStrength;
69126

70127
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
71-
public ExchangeAlgorithmType KeyExchangeAlgorithm => _sslStream.KeyExchangeAlgorithm;
128+
public ExchangeAlgorithmType KeyExchangeAlgorithm => _snapshotted ? _keyExchangeAlgorithm : _sslStream.KeyExchangeAlgorithm;
72129

73130
[Obsolete(Obsoletions.RuntimeTlsCipherAlgorithmEnumsMessage, DiagnosticId = Obsoletions.RuntimeTlsCipherAlgorithmEnumsDiagId, UrlFormat = Obsoletions.RuntimeSharedUrlFormat)]
74-
public int KeyExchangeStrength => _sslStream.KeyExchangeStrength;
131+
public int KeyExchangeStrength => _snapshotted ? _keyExchangeStrength : _sslStream.KeyExchangeStrength;
75132

76133
public Task<X509Certificate2?> GetClientCertificateAsync(CancellationToken cancellationToken)
77134
{

src/Servers/Kestrel/Core/src/Middleware/HttpsConnectionMiddleware.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ public async Task OnConnectionAsync(ConnectionContext context)
187187
}
188188
catch (OperationCanceledException ex)
189189
{
190+
feature.Exception = ex;
191+
feature.Snapshot();
190192
RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), metricsContext, metricsTagsFeature, ex);
191193

192194
_logger.AuthenticationTimedOut();
@@ -195,6 +197,8 @@ public async Task OnConnectionAsync(ConnectionContext context)
195197
}
196198
catch (IOException ex)
197199
{
200+
feature.Exception = ex;
201+
feature.Snapshot();
198202
RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), metricsContext, metricsTagsFeature, ex);
199203

200204
_logger.AuthenticationFailed(ex);
@@ -203,6 +207,8 @@ public async Task OnConnectionAsync(ConnectionContext context)
203207
}
204208
catch (AuthenticationException ex)
205209
{
210+
feature.Exception = ex;
211+
feature.Snapshot();
206212
RecordHandshakeFailed(_metrics, startTimestamp, Stopwatch.GetTimestamp(), metricsContext, metricsTagsFeature, ex);
207213

208214
_logger.AuthenticationFailed(ex);
@@ -238,7 +244,16 @@ public async Task OnConnectionAsync(ConnectionContext context)
238244
await using (sslStream)
239245
await using (sslDuplexPipe)
240246
{
241-
await _next(context);
247+
try
248+
{
249+
await _next(context);
250+
}
251+
finally
252+
{
253+
// Snapshot SslStream-backed properties before disposal so outer middleware
254+
// can still read ITlsHandshakeFeature after the connection completes.
255+
feature.Snapshot();
256+
}
242257
// Dispose the inner stream (SslDuplexPipe) before disposing the SslStream
243258
// as the duplex pipe can hit an ODE as it still may be writing.
244259
}

src/Servers/Kestrel/samples/SampleApp/Startup.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,27 @@ public static Task Main(string[] args)
105105

106106
options.Listen(IPAddress.Loopback, basePort + 1, listenOptions =>
107107
{
108-
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1;
108+
listenOptions.Use(next => async context =>
109+
{
110+
await next(context);
111+
112+
var tlsHandshakeFeature = context.Features.Get<ITlsHandshakeFeature>();
113+
if (tlsHandshakeFeature?.Exception is { } ex)
114+
{
115+
Console.WriteLine($"[TLS Handshake Failed] ConnectionId={context.ConnectionId}, Exception={ex.GetType().Name}: {ex.Message}");
116+
}
117+
});
118+
109119
listenOptions.UseHttps();
110120
listenOptions.UseConnectionLogging();
121+
122+
listenOptions.Use(next => async context =>
123+
{
124+
var tlsHandshakeFeature = context.Features.Get<ITlsHandshakeFeature>();
125+
Console.WriteLine($"[TLS Handshake OK] ConnectionId={context.ConnectionId}, Protocol={tlsHandshakeFeature.Protocol}");
126+
127+
await next(context);
128+
});
111129
});
112130

113131
options.ListenLocalhost(basePort + 2, listenOptions =>

src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpsConnectionMiddlewareTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,107 @@ void ConfigureListenOptions(ListenOptions listenOptions)
202202
}
203203
}
204204

205+
[Fact]
206+
public async Task HandshakeExceptionIsAvailableAfterHandshakeFailure()
207+
{
208+
var handshakeFeatureTcs = new TaskCompletionSource<ITlsHandshakeFeature>(TaskCreationOptions.RunContinuationsAsynchronously);
209+
210+
void ConfigureListenOptions(ListenOptions listenOptions)
211+
{
212+
// Outer middleware wraps the HTTPS middleware and can inspect the feature after it returns.
213+
listenOptions.Use(next => async connectionContext =>
214+
{
215+
await next(connectionContext);
216+
217+
var feature = connectionContext.Features.Get<ITlsHandshakeFeature>();
218+
handshakeFeatureTcs.TrySetResult(feature);
219+
});
220+
221+
listenOptions.UseHttps(new HttpsConnectionAdapterOptions { ServerCertificate = _x509Certificate2 });
222+
}
223+
224+
await using (var server = new TestServer(context => Task.CompletedTask, new TestServiceContext(LoggerFactory), ConfigureListenOptions))
225+
{
226+
using var connection = server.CreateConnection();
227+
await using var sslStream = new SslStream(connection.Stream);
228+
229+
var clientAuthOptions = new SslClientAuthenticationOptions
230+
{
231+
TargetHost = "localhost",
232+
// Only enabling an obsolete protocol should cause a handshake failure.
233+
#pragma warning disable CS0618 // Type or member is obsolete
234+
EnabledSslProtocols = SslProtocols.Ssl2,
235+
#pragma warning restore CS0618
236+
};
237+
238+
using var handshakeCts = new CancellationTokenSource(TestConstants.DefaultTimeout);
239+
await Assert.ThrowsAnyAsync<Exception>(() => sslStream.AuthenticateAsClientAsync(clientAuthOptions, handshakeCts.Token));
240+
241+
var handshakeFeature = await handshakeFeatureTcs.Task.DefaultTimeout();
242+
Assert.NotNull(handshakeFeature);
243+
Assert.NotNull(handshakeFeature.Exception);
244+
}
245+
}
246+
247+
[Fact]
248+
public async Task HandshakeFeaturePropertiesAreAccessibleAfterHandshakeTimeout()
249+
{
250+
var handshakeFeatureTcs = new TaskCompletionSource<ITlsHandshakeFeature>(TaskCreationOptions.RunContinuationsAsynchronously);
251+
252+
void ConfigureListenOptions(ListenOptions listenOptions)
253+
{
254+
listenOptions.Use(next => async connectionContext =>
255+
{
256+
await next(connectionContext);
257+
258+
var feature = connectionContext.Features.Get<ITlsHandshakeFeature>();
259+
handshakeFeatureTcs.TrySetResult(feature);
260+
});
261+
262+
listenOptions.UseHttps(o =>
263+
{
264+
o.ServerCertificate = _x509Certificate2;
265+
o.HandshakeTimeout = TimeSpan.FromSeconds(2);
266+
});
267+
}
268+
269+
await using (var server = new TestServer(context => Task.CompletedTask, new TestServiceContext(LoggerFactory), ConfigureListenOptions))
270+
{
271+
using var connection = server.CreateConnection();
272+
// Don't send any TLS data — let the handshake time out.
273+
Assert.Equal(0, await connection.Stream.ReadAsync(new byte[1], 0, 1).DefaultTimeout());
274+
275+
var handshakeFeature = await handshakeFeatureTcs.Task.DefaultTimeout();
276+
Assert.NotNull(handshakeFeature);
277+
Assert.IsAssignableFrom<OperationCanceledException>(handshakeFeature.Exception);
278+
}
279+
}
280+
281+
[Fact]
282+
public async Task HandshakeExceptionIsNullOnSuccessfulHandshake()
283+
{
284+
ITlsHandshakeFeature capturedFeature = null;
285+
286+
void ConfigureListenOptions(ListenOptions listenOptions)
287+
{
288+
listenOptions.UseHttps(new HttpsConnectionAdapterOptions { ServerCertificate = _x509Certificate2 });
289+
}
290+
291+
await using (var server = new TestServer(context =>
292+
{
293+
capturedFeature = context.Features.Get<ITlsHandshakeFeature>();
294+
Assert.NotNull(capturedFeature);
295+
Assert.Null(capturedFeature.Exception);
296+
return context.Response.WriteAsync("hello world");
297+
}, new TestServiceContext(LoggerFactory), ConfigureListenOptions))
298+
{
299+
var result = await server.HttpClientSlim.GetStringAsync($"https://localhost:{server.Port}/", validateCertificate: false);
300+
Assert.Equal("hello world", result);
301+
}
302+
303+
Assert.NotNull(capturedFeature);
304+
}
305+
205306
[Fact]
206307
public async Task RequireCertificateFailsWhenNoCertificate()
207308
{

0 commit comments

Comments
 (0)