Skip to content

Commit b2cff22

Browse files
Use explicit hostname in DotNetty certificate validator (#8465)
* Clarify DotNetty hostname validation direction * Use explicit hostname in certificate validator
1 parent 0cbf8d1 commit b2cff22

6 files changed

Lines changed: 127 additions & 30 deletions

File tree

docs/articles/remoting/security.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,10 @@ When `suppress-validation = true`:
125125

126126
### Validation Strategies: HOCON vs Programmatic (v1.5.52+)
127127

128-
Two independent validation decisions determine your TLS security posture:
128+
Three independent validation decisions determine your TLS security posture:
129129

130130
1. **Chain Validation** - Verify certificate against trusted CAs (`suppress-validation`)
131-
2. **Hostname Validation** - Verify certificate CN/SAN matches target (`validate-certificate-hostname`)
131+
2. **Hostname Validation** - Verify an outbound server certificate CN/SAN matches the connection target (`validate-certificate-hostname`)
132132
3. **Mutual Authentication** - Require both sides authenticate (`require-mutual-authentication`)
133133

134134
#### Decision Matrix: Which Combination to Use
@@ -151,10 +151,12 @@ When `validate-certificate-hostname = false` (the default):
151151

152152
When `validate-certificate-hostname = true`:
153153

154-
* Certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname
154+
* The outbound server certificate CN (Common Name) or SAN (Subject Alternative Name) must match the target hostname
155155
* Traditional TLS hostname validation as used in HTTPS
156156
* **Best for:** Client-server architectures with shared certificates and stable DNS names
157157

158+
Hostname validation is an outbound server-identity check: the connecting node knows the hostname it intends to reach. The receiving node does not have an independently known hostname for an inbound client, so this setting does not infer one from the client's IP address or certificate. Use a `DotNettySslSetup` custom validator when inbound clients must satisfy application-specific identity or authorization rules such as certificate pinning, subject/issuer checks, or an explicit expected identity.
159+
158160
**HOCON Example - P2P Cluster (Common Default):**
159161

160162
```hocon
@@ -370,7 +372,7 @@ Perform standard chain validation, then apply custom business logic:
370372

371373
#### Hostname Validation
372374

373-
Enable traditional TLS hostname validation (certificate CN/SAN must match target hostname). Use for client-server architectures with shared certificates:
375+
Enable traditional outbound TLS hostname validation (the server certificate CN/SAN must match the connection target). Use for client-server architectures with stable DNS names:
374376

375377
[!code-csharp[HostnameValidationExample](../../../src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs?name=HostnameValidationExample)]
376378

@@ -385,7 +387,7 @@ Accept only certificates with specific subject names:
385387
| Method | Purpose |
386388
|--------|---------|
387389
| `ValidateChain()` | CA chain validation with full error details |
388-
| `ValidateHostname()` | Traditional TLS hostname validation (CN/SAN matching) |
390+
| `ValidateHostname()` | Uses the outbound `SslStream` result, or performs a direct CN/SAN comparison when an expected hostname is supplied |
389391
| `PinnedCertificate()` | Certificate pinning by thumbprint whitelist |
390392
| `ValidateSubject()` | Subject DN pattern matching (e.g., CN, O, OU) |
391393
| `ValidateIssuer()` | Issuer DN pattern matching |

src/core/Akka.Docs.Tests/Configuration/TlsConfigurationSample.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,19 +179,19 @@ public static void CustomValidationLogicSetup()
179179

180180
#region HostnameValidationExample
181181
/// <summary>
182-
/// Example of enabling traditional hostname validation for client-server architectures.
182+
/// Example of enabling traditional outbound hostname validation for client-server architectures.
183183
/// Use when all nodes share the same certificate with matching CN/SAN.
184184
/// </summary>
185185
public static void HostnameValidationSetup()
186186
{
187187
var certificate = LoadCertificate("path/to/certificate.pfx", "password");
188188

189-
// Enable both chain validation and hostname validation
189+
// Enable both chain validation and outbound server hostname validation
190190
var sslSetup = new DotNettySslSetup(
191191
certificate: certificate,
192192
suppressValidation: false,
193193
requireMutualAuthentication: true,
194-
validateCertificateHostname: true // Enable traditional TLS hostname validation
194+
validateCertificateHostname: true // Validate the outbound server certificate against the target hostname
195195
);
196196
}
197197
#endregion
@@ -221,4 +221,4 @@ public static void SubjectValidationSetup()
221221
}
222222
#endregion
223223
}
224-
}
224+
}

src/core/Akka.Remote.Tests/Transport/CertificateValidationHelpersSpec.cs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
using System;
99
using System.Net.Security;
10+
using System.Security.Cryptography;
1011
using System.Security.Cryptography.X509Certificates;
1112
using Akka.Event;
1213
using Akka.Remote.Transport.DotNetty;
@@ -21,6 +22,7 @@ namespace Akka.Remote.Tests.Transport
2122
public class CertificateValidationHelpersSpec : AkkaSpec
2223
{
2324
private const string ValidCertPath = "Resources/akka-validcert.pfx";
25+
private const string ClientCertPath = "Resources/akka-client-cert.pfx";
2426
private const string Password = "password";
2527
private readonly ILoggingAdapter _log;
2628

@@ -139,6 +141,67 @@ public void PinnedCertificate_should_reject_non_matching_thumbprint()
139141

140142
#endregion
141143

144+
#region ValidateHostname Tests
145+
146+
[Fact(DisplayName = "ValidateHostname should compare an explicitly supplied expected hostname")]
147+
public void ValidateHostname_should_compare_explicit_expected_hostname()
148+
{
149+
using var cert = CertificateHelper.LoadPkcs12(ClientCertPath, Password);
150+
var matchingValidator = CertificateValidation.ValidateHostname("AkkaTestClient");
151+
var rejectingValidator = CertificateValidation.ValidateHostname("unrelated.invalid");
152+
153+
Assert.True(matchingValidator(cert, null, "test-peer", SslPolicyErrors.None, _log));
154+
EventFilter.Error(contains: "expected 'unrelated.invalid'").ExpectOne(() =>
155+
{
156+
Assert.False(rejectingValidator(cert, null, "test-peer", SslPolicyErrors.None, _log));
157+
});
158+
}
159+
160+
[Fact(DisplayName = "ValidateHostname should match any DNS subject alternative name")]
161+
public void ValidateHostname_should_match_any_DNS_subject_alternative_name()
162+
{
163+
using var key = RSA.Create(2048);
164+
var request = new CertificateRequest("CN=client.internal", key, HashAlgorithmName.SHA256,
165+
RSASignaturePadding.Pkcs1);
166+
var subjectAlternativeNames = new SubjectAlternativeNameBuilder();
167+
subjectAlternativeNames.AddDnsName("first.internal");
168+
subjectAlternativeNames.AddDnsName("second.internal");
169+
request.CertificateExtensions.Add(subjectAlternativeNames.Build());
170+
using var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1),
171+
DateTimeOffset.UtcNow.AddMinutes(5));
172+
var validator = CertificateValidation.ValidateHostname("second.internal");
173+
174+
Assert.True(validator(cert, null, "test-peer", SslPolicyErrors.None, _log));
175+
}
176+
177+
[Fact(DisplayName = "ValidateHostname without an expected hostname should use SslStream policy errors")]
178+
public void ValidateHostname_without_expected_hostname_should_use_policy_errors()
179+
{
180+
using var cert = CertificateHelper.LoadPkcs12(ClientCertPath, Password);
181+
var validator = CertificateValidation.ValidateHostname();
182+
183+
Assert.True(validator(cert, null, "test-peer", SslPolicyErrors.None, _log));
184+
EventFilter.Error(contains: "Hostname validation failed").ExpectOne(() =>
185+
{
186+
Assert.False(validator(cert, null, "test-peer",
187+
SslPolicyErrors.RemoteCertificateNameMismatch, _log));
188+
});
189+
}
190+
191+
[Fact(DisplayName = "ValidateHostname should reject an invalid explicit hostname")]
192+
public void ValidateHostname_should_reject_invalid_explicit_hostname()
193+
{
194+
using var cert = CertificateHelper.LoadPkcs12(ClientCertPath, Password);
195+
var validator = CertificateValidation.ValidateHostname("not a valid hostname!");
196+
197+
EventFilter.Error(contains: "unable to validate expected hostname").ExpectOne(() =>
198+
{
199+
Assert.False(validator(cert, null, "test-peer", SslPolicyErrors.None, _log));
200+
});
201+
}
202+
203+
#endregion
204+
142205
#region ValidateSubject Tests
143206

144207
[Fact(DisplayName = "ValidateSubject should reject null certificate")]
@@ -242,4 +305,4 @@ public void Combine_should_short_circuit_on_first_failure()
242305

243306
#endregion
244307
}
245-
}
308+
}

src/core/Akka.Remote/Configuration/Remote.conf

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,8 @@ akka {
576576
require-mutual-authentication = true
577577

578578
# Enable or disable certificate hostname validation during TLS handshake.
579-
# When true: Traditional TLS hostname validation is performed (certificate CN/SAN must match target hostname)
579+
# When true: Traditional outbound TLS hostname validation is performed
580+
# (the server certificate CN/SAN must match the connection target hostname)
580581
# When false: Only validates certificate chain against CA, ignores hostname mismatches
581582
#
582583
# Set to false for scenarios such as:
@@ -585,6 +586,9 @@ akka {
585586
# - Service discovery with dynamic addresses
586587
#
587588
# Default: false (disabled for backward compatibility and mutual TLS flexibility)
589+
#
590+
# This setting does not infer a hostname for inbound client certificates. Use a
591+
# DotNettySslSetup custom validator for application-specific inbound client authorization.
588592
validate-certificate-hostname = false
589593
}
590594
}

src/core/Akka.Remote/Transport/DotNetty/DotNettySslSetup.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace Akka.Remote.Transport.DotNetty;
1515
/// Programmatic setup for DotNetty SSL/TLS configuration.
1616
/// Provides a fluent API alternative to HOCON configuration.
1717
/// </summary>
18-
public sealed class DotNettySslSetup: Setup
18+
public sealed class DotNettySslSetup : Setup
1919
{
2020
/// <summary>
2121
/// Constructor for backward compatibility - defaults to RequireMutualAuthentication = true, ValidateCertificateHostname = false
@@ -44,7 +44,7 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, b
4444
/// <param name="certificate">X509 certificate used to establish SSL/TLS</param>
4545
/// <param name="suppressValidation">When true, suppresses certificate chain validation (use only for development/testing)</param>
4646
/// <param name="requireMutualAuthentication">When true, requires mutual TLS authentication (both client and server present certificates)</param>
47-
/// <param name="validateCertificateHostname">When true, enables hostname validation (certificate CN/SAN must match target hostname)</param>
47+
/// <param name="validateCertificateHostname">When true, enables outbound hostname validation (server certificate CN/SAN must match target hostname)</param>
4848
public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname)
4949
: this(certificate, suppressValidation, requireMutualAuthentication, validateCertificateHostname, customValidator: null)
5050
{
@@ -68,7 +68,7 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, b
6868
/// <param name="certificate">X509 certificate used to establish SSL/TLS</param>
6969
/// <param name="suppressValidation">When true, suppresses certificate chain validation (use only for development/testing)</param>
7070
/// <param name="requireMutualAuthentication">When true, requires mutual TLS authentication (both client and server present certificates)</param>
71-
/// <param name="validateCertificateHostname">When true, enables hostname validation (certificate CN/SAN must match target hostname)</param>
71+
/// <param name="validateCertificateHostname">When true, enables outbound hostname validation (server certificate CN/SAN must match target hostname)</param>
7272
/// <param name="customValidator">Custom certificate validation callback (overrides config-based validation when provided)</param>
7373
public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname, CertificateValidationCallback? customValidator)
7474
{
@@ -97,7 +97,8 @@ public DotNettySslSetup(X509Certificate2 certificate, bool suppressValidation, b
9797
public bool RequireMutualAuthentication { get; }
9898

9999
/// <summary>
100-
/// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname).
100+
/// When true, enables traditional outbound TLS hostname validation
101+
/// (the server certificate CN/SAN must match the connection target hostname).
101102
/// When false, only validates certificate chain against CA, ignores hostname mismatches.
102103
/// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates,
103104
/// IP-based connections, or dynamic service discovery.

src/core/Akka.Remote/Transport/DotNetty/DotNettyTransportSettings.cs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Linq;
1212
using System.Net;
1313
using System.Net.Security;
14+
using System.Security.Cryptography;
1415
using System.Security.Cryptography.X509Certificates;
1516
using Akka.Actor;
1617
using Akka.Configuration;
@@ -114,10 +115,10 @@ namespace Akka.Remote.Transport.DotNetty
114115
/// Used for performance-tuning the DotNetty channels to maximize I/O performance.
115116
/// </param>
116117
internal sealed record DotNettyTransportSettings(
117-
TransportMode TransportMode,
118+
TransportMode TransportMode,
118119
bool EnableSsl,
119120
TimeSpan ConnectTimeout,
120-
string Hostname,
121+
string Hostname,
121122
string PublicHostname,
122123
int Port,
123124
int? PublicPort,
@@ -132,7 +133,7 @@ internal sealed record DotNettyTransportSettings(
132133
int Backlog,
133134
bool EnforceIpFamily,
134135
int? ReceiveBufferSize,
135-
int? SendBufferSize,
136+
int? SendBufferSize,
136137
int? WriteBufferHighWaterMark,
137138
int? WriteBufferLowWaterMark,
138139
bool BackwardsCompatibilityModeEnabled,
@@ -193,7 +194,7 @@ public static DotNettyTransportSettings Create(Config config, SslSettings? sslSe
193194

194195
var transportMode = config.GetString("transport-protocol", "tcp").ToLower();
195196
var host = config.GetString("hostname");
196-
if (string.IsNullOrWhiteSpace(host))
197+
if (string.IsNullOrWhiteSpace(host))
197198
host = IPAddress.Any.ToString();
198199

199200
var publicHost = config.GetString("public-hostname");
@@ -257,7 +258,7 @@ private static int ComputeWorkerPoolSize(Config config)
257258

258259
internal DotNettyTransportSettings Validate()
259260
{
260-
if (MaxFrameSize < 32000)
261+
if (MaxFrameSize < 32000)
261262
throw new ArgumentException("maximum-frame-size must be at least 32000 bytes", nameof(MaxFrameSize));
262263

263264
return this;
@@ -367,7 +368,8 @@ private static X509KeyStorageFlags ParseKeyStorageFlag(string str)
367368
public readonly bool RequireMutualAuthentication;
368369

369370
/// <summary>
370-
/// When true, enables traditional TLS hostname validation (certificate CN/SAN must match target hostname).
371+
/// When true, enables traditional outbound TLS hostname validation
372+
/// (the server certificate CN/SAN must match the connection target hostname).
371373
/// When false, only validates certificate chain against CA, ignores hostname mismatches.
372374
/// Default is false for backward compatibility and to support mutual TLS scenarios with per-node certificates,
373375
/// IP-based connections, or dynamic service discovery.
@@ -565,10 +567,13 @@ public static CertificateValidationCallback ValidateChain(
565567
}
566568

567569
/// <summary>
568-
/// Validate certificate hostname (CN/SAN) matches expected hostname.
569-
/// Use for: Per-node certificates, FQDN-based identity.
570-
/// Applies bidirectionally on both client and server.
570+
/// Without an explicit hostname, validates the outbound server certificate using the hostname result
571+
/// supplied by <see cref="SslStream"/>. When <paramref name="expectedHostname"/> is supplied, compares
572+
/// that name directly against the certificate CN/SAN.
573+
/// This helper does not infer a hostname for inbound client certificates.
571574
/// </summary>
575+
/// <param name="expectedHostname">Optional hostname to compare directly against the certificate.</param>
576+
/// <param name="log">Optional logger for validation failures.</param>
572577
public static CertificateValidationCallback ValidateHostname(
573578
string? expectedHostname = null,
574579
ILoggingAdapter? log = null)
@@ -583,15 +588,37 @@ public static CertificateValidationCallback ValidateHostname(
583588
return false;
584589
}
585590

586-
var hostname = expectedHostname ?? peer;
591+
if (expectedHostname == null)
592+
{
593+
if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) == 0)
594+
return true;
587595

588-
if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) == 0) return true;
589-
var cn = cert.GetNameInfo(X509NameType.DnsName, false);
596+
var certificateName = cert.GetNameInfo(X509NameType.DnsName, false);
597+
(log ?? nonClosureLog).Error(
598+
"Hostname validation failed for {0}: certificate name is '{1}'",
599+
peer, certificateName);
600+
return false;
601+
}
602+
603+
try
604+
{
605+
if (cert.MatchesHostname(expectedHostname))
606+
return true;
607+
}
608+
catch (Exception ex) when (ex is ArgumentException or CryptographicException)
609+
{
610+
(log ?? nonClosureLog).Error(
611+
ex,
612+
"Hostname validation failed for {0}: unable to validate expected hostname '{1}'",
613+
peer, expectedHostname);
614+
return false;
615+
}
616+
617+
var name = cert.GetNameInfo(X509NameType.DnsName, false);
590618
(log ?? nonClosureLog).Error(
591-
"Hostname validation failed for {0}: expected '{1}', certificate CN is '{2}'",
592-
peer, hostname, cn);
619+
"Hostname validation failed for {0}: expected '{1}', certificate name is '{2}'",
620+
peer, expectedHostname, name);
593621
return false;
594-
595622
};
596623
}
597624

0 commit comments

Comments
 (0)