Skip to content

Commit 6ccfecf

Browse files
Fix TLS hostname validation bug and add configurable validation (#7897)
* Fix TLS hostname validation bug and add configurable validation (#7893) ## Summary This commit addresses GitHub issue #7893 by fixing a critical TLS hostname validation bug and introducing a configurable, type-safe validation system. ## Changes ### Bug Fix - **Fixed DotNettyTransport.cs:355** - TLS client was incorrectly validating against the client's own certificate DNS name instead of the remote server address - Changed from `certificate.GetNameInfo(X509NameType.DnsName, false)` to `remoteAddress.Host` ### New Configuration - Added `validate-certificate-hostname` config option to Remote.conf - Default: `false` (disabled for backward compatibility and mutual TLS flexibility) - When enabled: Traditional TLS hostname validation (CN/SAN must match target hostname) - When disabled: Only validates certificate chain, ignores hostname mismatches - Useful for: Mutual TLS with per-node certificates, IP-based connections, dynamic service discovery ### Type-Safe Validation System - Introduced enums to prevent primitive confusion in security-critical code: - `ChainValidationMode` enum (ValidateChain, IgnoreChainErrors) - `HostnameValidationMode` enum (ValidateHostname, IgnoreHostnameMismatch) - Created `TlsValidationCallbacks` static factory class with: - Main `Create()` method accepting enum parameters and logging adapter - Convenience methods: `ValidateFull()`, `ValidateChainOnly()`, `ValidateHostnameOnly()`, `AcceptAll()` - Detailed error logging with filtered SslPolicyErrors - Makes validation flags independent and composable - Replaces ~35 lines of inline callback code with 3 lines of self-documenting factory calls ### Updated SslSettings - Added `ValidateCertificateHostname` property - Updated all constructors to accept the new property - Updated `Create()` method to read from HOCON config ### Test Coverage - Extended `DotNettyMutualTlsSpec` with 4 new test cases: - `Hostname_validation_disabled_should_allow_different_certificates()` - Different certs work with validation disabled - `Hostname_validation_enabled_should_reject_different_certificates()` - Different certs fail with validation enabled - `Same_certificate_should_connect_in_mutual_tls()` - Typical mutual TLS scenario - `Hostname_validation_default_should_be_disabled()` - Verifies backward compatibility ## Technical Details Chain validation and hostname validation are now fully independent: - `suppressValidation=true` disables chain/CA validation (for self-signed certs) - `validateCertificateHostname=true/false` controls hostname matching (for per-node certs, IPs) This allows testing hostname validation with self-signed certificates by using `suppressValidation=true, validateCertificateHostname=true`. Fixes #7893 * Extend DotNettySslSetup to expose all SSL/TLS configuration options ## Summary Extended `DotNettySslSetup` programmatic API to expose the full SSL/TLS configuration, including the newly added hostname validation setting and the existing RequireMutualAuthentication setting that was previously only available via HOCON. ## Changes ### DotNettySslSetup API - Added `RequireMutualAuthentication` property (was missing from programmatic API) - Added `ValidateCertificateHostname` property (new setting from #7893) - Added comprehensive XML documentation for all properties and constructors - Added backward-compatible constructors: - 2-parameter: Defaults to RequireMutualAuthentication=true, ValidateCertificateHostname=false - 3-parameter: Defaults to ValidateCertificateHostname=false - 4-parameter: Full control over all settings - Updated `Settings` property to pass all 4 parameters to `SslSettings` constructor ### Integration Tests - Added 3 integration tests in `DotNettySslSetupSpec`: - Verify 2-parameter setup configures effective DotNettyTransportSettings with expected defaults - Verify 3-parameter setup configures effective settings with specified RequireMutualAuth - Verify 4-parameter setup configures effective settings with all specified values - Tests verify the actual consumption path: ActorSystem → DotNettyTransportSettings.Create() → setup.Value.Settings - Tests validate that setup values correctly override HOCON defaults ## Backward Compatibility All existing code using the 2-parameter constructor continues to work with the same defaults: - RequireMutualAuthentication: true (matches previous HOCON-only behavior) - ValidateCertificateHostname: false (matches new HOCON default) The setup is properly consumed in `DotNettyTransportSettings.Create(ActorSystem)` which retrieves the setup via `system.Settings.Setup.Get<DotNettySslSetup>()` and calls `setup.Value.Settings` to get the fully configured `SslSettings` object. * Update security documentation for hostname validation feature ## Changes - Explained the new independent validation system (chain vs hostname) - Added details about default certificate stores used (Windows, Linux, macOS) - Documented the `validate-certificate-hostname` setting with use cases - Added validation mode combination table - Included configuration examples for both P2P and client-server scenarios - Added comprehensive troubleshooting for hostname validation errors - Documented enhanced TLS error messages from v1.5.52 - Reduced emoji usage for more professional tone - Added links to Microsoft documentation and RFC specifications ## Key Documentation Updates ### New Sections - Certificate Validation: Independent Control - Hostname Validation setting explanation - Validation Mode Combinations table - Configuration with Hostname Validation Enabled - Enhanced error message examples ### Troubleshooting Additions - RemoteCertificateNameMismatch errors (hostname validation failures) - UntrustedRoot errors (chain validation failures) - Understanding TLS Error Messages section with real examples - Multiple fix options for each error scenario ### Technical Details - Explained which OS certificate stores are used by default - Referenced RFC 5280 and RFC 6125 for validation standards - Clarified that suppress-validation only controls chain validation - Clarified that hostname validation is separate and optional * Fix markdown linting and spellcheck issues in security docs - Add blank lines around all fenced code blocks - Add language specifiers to code blocks (text, hocon, bash, powershell) - Change dash lists to asterisk lists for consistency - Add 'hostnames' to spellcheck dictionary - Emphasize that hostname validation defaults to false (disabled) * Improve documentation heading structure and title case - Remove technical setting names from headings - Use descriptive section titles instead - Change subheadings to 'Enabled/Disabled' pattern - Move technical details into content body - Fix title case linting issues This makes the documentation more scannable and separates conceptual sections from implementation details. * added api approvals
1 parent 251bcd9 commit 6ccfecf

File tree

10 files changed

+793
-70
lines changed

10 files changed

+793
-70
lines changed

docs/articles/remoting/security.md

Lines changed: 276 additions & 32 deletions
Large diffs are not rendered by default.

docs/cSpell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"Hasher",
3737
"Hipsterize",
3838
"HOCON",
39+
"hostnames",
3940
"journaled",
4041
"Kubernetes",
4142
"lifecycles",

src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.DotNet.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty
863863
public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup
864864
{
865865
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { }
866+
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { }
867+
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { }
866868
public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; }
869+
public bool RequireMutualAuthentication { get; }
867870
public bool SuppressValidation { get; }
871+
public bool ValidateCertificateHostname { get; }
868872
}
869873
}

src/core/Akka.API.Tests/verify/CoreAPISpec.ApproveRemote.Net.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,11 @@ namespace Akka.Remote.Transport.DotNetty
863863
public sealed class DotNettySslSetup : Akka.Actor.Setup.Setup
864864
{
865865
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation) { }
866+
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication) { }
867+
public DotNettySslSetup(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, bool suppressValidation, bool requireMutualAuthentication, bool validateCertificateHostname) { }
866868
public System.Security.Cryptography.X509Certificates.X509Certificate2 Certificate { get; }
869+
public bool RequireMutualAuthentication { get; }
867870
public bool SuppressValidation { get; }
871+
public bool ValidateCertificateHostname { get; }
868872
}
869873
}

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

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public DotNettyMutualTlsSpec(ITestOutputHelper output) : base(ConfigurationFacto
3030
{
3131
}
3232

33-
private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null)
33+
private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool suppressValidation = false, string certPath = null, bool? validateCertificateHostname = null)
3434
{
3535
var config = ConfigurationFactory.ParseString($@"
3636
akka {{
@@ -49,10 +49,15 @@ private static Config CreateConfig(bool enableSsl, bool requireMutualAuth, bool
4949
return config;
5050

5151
var escapedPath = (certPath ?? ValidCertPath).Replace("\\", "\\\\");
52+
var hostnameValidationConfig = validateCertificateHostname.HasValue
53+
? $"validate-certificate-hostname = {(validateCertificateHostname.Value ? "on" : "off")}"
54+
: "";
55+
5256
var ssl = $@"
5357
akka.remote.dot-netty.tcp.ssl {{
5458
suppress-validation = {(suppressValidation ? "on" : "off")}
5559
require-mutual-authentication = {(requireMutualAuth ? "on" : "off")}
60+
{hostnameValidationConfig}
5661
certificate {{
5762
path = ""{escapedPath}""
5863
password = ""{Password}""
@@ -275,6 +280,165 @@ await Assert.ThrowsAsync<AskTimeoutException>(async () =>
275280
}
276281
}
277282

283+
[Fact(DisplayName = "Different certificates with hostname validation disabled should connect successfully")]
284+
public async Task Hostname_validation_disabled_should_allow_different_certificates()
285+
{
286+
// Per-node certificates should work when hostname validation is disabled
287+
// Note: Using suppressValidation=true to bypass chain validation since test certs are self-signed
288+
// This isolates the hostname validation logic we're testing
289+
ActorSystem server = null;
290+
ActorSystem client = null;
291+
292+
try
293+
{
294+
// Server with one certificate, hostname validation disabled
295+
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
296+
certPath: ValidCertPath, validateCertificateHostname: false);
297+
server = ActorSystem.Create("ServerSystem", serverConfig);
298+
InitializeLogger(server, "[SERVER] ");
299+
300+
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
301+
var serverAddr = RARP.For(server).Provider.DefaultAddress;
302+
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
303+
304+
// Client with different certificate, hostname validation disabled
305+
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
306+
certPath: ClientCertPath, validateCertificateHostname: false);
307+
client = ActorSystem.Create("ClientSystem", clientConfig);
308+
InitializeLogger(client, "[CLIENT] ");
309+
310+
// Should successfully connect because hostname validation is disabled
311+
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
312+
Assert.Equal("hello", response);
313+
}
314+
finally
315+
{
316+
if (client != null)
317+
Shutdown(client, TimeSpan.FromSeconds(10));
318+
if (server != null)
319+
Shutdown(server, TimeSpan.FromSeconds(10));
320+
}
321+
}
322+
323+
[Fact(DisplayName = "Different certificates with hostname validation enabled should fail with name mismatch")]
324+
public async Task Hostname_validation_enabled_should_reject_different_certificates()
325+
{
326+
// When hostname validation is enabled, different certificates should fail with RemoteCertificateNameMismatch
327+
// Note: Using suppressValidation=true to bypass chain validation and test hostname validation specifically
328+
ActorSystem server = null;
329+
ActorSystem client = null;
330+
331+
try
332+
{
333+
// Server with one certificate, hostname validation enabled
334+
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
335+
certPath: ValidCertPath, validateCertificateHostname: true);
336+
server = ActorSystem.Create("ServerSystem", serverConfig);
337+
InitializeLogger(server, "[SERVER] ");
338+
339+
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
340+
var serverAddr = RARP.For(server).Provider.DefaultAddress;
341+
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
342+
343+
// Client with different certificate, hostname validation enabled
344+
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
345+
certPath: ClientCertPath, validateCertificateHostname: true);
346+
client = ActorSystem.Create("ClientSystem", clientConfig);
347+
InitializeLogger(client, "[CLIENT] ");
348+
349+
// Should fail because hostname in certificate doesn't match connection target (127.0.0.1)
350+
await Assert.ThrowsAsync<AskTimeoutException>(async () =>
351+
{
352+
await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(3));
353+
});
354+
}
355+
finally
356+
{
357+
if (client != null)
358+
Shutdown(client, TimeSpan.FromSeconds(10));
359+
if (server != null)
360+
Shutdown(server, TimeSpan.FromSeconds(10));
361+
}
362+
}
363+
364+
[Fact(DisplayName = "Same certificate should connect successfully (typical mutual TLS scenario)")]
365+
public async Task Same_certificate_should_connect_in_mutual_tls()
366+
{
367+
// Typical mutual TLS: Both nodes use the same shared certificate
368+
// Hostname validation disabled because we're using IPs/per-node certs
369+
ActorSystem server = null;
370+
ActorSystem client = null;
371+
372+
try
373+
{
374+
// Server with same certificate, hostname validation disabled (typical for mutual TLS)
375+
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
376+
certPath: ValidCertPath, validateCertificateHostname: false);
377+
server = ActorSystem.Create("ServerSystem", serverConfig);
378+
InitializeLogger(server, "[SERVER] ");
379+
380+
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
381+
var serverAddr = RARP.For(server).Provider.DefaultAddress;
382+
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
383+
384+
// Client with same certificate, hostname validation disabled
385+
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
386+
certPath: ValidCertPath, validateCertificateHostname: false);
387+
client = ActorSystem.Create("ClientSystem", clientConfig);
388+
InitializeLogger(client, "[CLIENT] ");
389+
390+
// Should successfully connect - typical mutual TLS scenario
391+
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
392+
Assert.Equal("hello", response);
393+
}
394+
finally
395+
{
396+
if (client != null)
397+
Shutdown(client, TimeSpan.FromSeconds(10));
398+
if (server != null)
399+
Shutdown(server, TimeSpan.FromSeconds(10));
400+
}
401+
}
402+
403+
[Fact(DisplayName = "Hostname validation unspecified should default to disabled (backward compatibility)")]
404+
public async Task Hostname_validation_default_should_be_disabled()
405+
{
406+
// When validate-certificate-hostname is not specified, it should default to false
407+
// Note: Using suppressValidation=true to bypass chain validation and test hostname default behavior
408+
ActorSystem server = null;
409+
ActorSystem client = null;
410+
411+
try
412+
{
413+
// Server without specifying hostname validation (should default to false)
414+
var serverConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
415+
certPath: ValidCertPath, validateCertificateHostname: null);
416+
server = ActorSystem.Create("ServerSystem", serverConfig);
417+
InitializeLogger(server, "[SERVER] ");
418+
419+
var serverEcho = server.ActorOf(Props.Create(() => new EchoActor()), "echo");
420+
var serverAddr = RARP.For(server).Provider.DefaultAddress;
421+
var serverEchoPath = new RootActorPath(serverAddr) / "user" / "echo";
422+
423+
// Client with different certificate, hostname validation unspecified (should default to false)
424+
var clientConfig = CreateConfig(enableSsl: true, requireMutualAuth: true, suppressValidation: true,
425+
certPath: ClientCertPath, validateCertificateHostname: null);
426+
client = ActorSystem.Create("ClientSystem", clientConfig);
427+
InitializeLogger(client, "[CLIENT] ");
428+
429+
// Should successfully connect because hostname validation defaults to disabled
430+
var response = await client.ActorSelection(serverEchoPath).Ask<string>("hello", TimeSpan.FromSeconds(5));
431+
Assert.Equal("hello", response);
432+
}
433+
finally
434+
{
435+
if (client != null)
436+
Shutdown(client, TimeSpan.FromSeconds(10));
437+
if (server != null)
438+
Shutdown(server, TimeSpan.FromSeconds(10));
439+
}
440+
}
441+
278442
private sealed class EchoActor : ReceiveActor
279443
{
280444
public EchoActor()

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,96 @@ await Assert.ThrowsAsync<RemoteTransportException>(async () =>
105105
});
106106
}
107107

108+
[Fact(DisplayName = "DotNettySslSetup with 2 parameters should configure effective DotNettyTransportSettings with defaults (RequireMutualAuth=true, ValidateHostname=false)")]
109+
public void Two_parameter_setup_should_configure_transport_settings_with_defaults()
110+
{
111+
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
112+
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true);
113+
114+
var actorSystemSetup = ActorSystemSetup.Empty
115+
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
116+
akka {
117+
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
118+
remote.dot-netty.tcp {
119+
port = 0
120+
hostname = ""127.0.0.1""
121+
enable-ssl = true
122+
}
123+
}")))
124+
.And(sslSetup);
125+
126+
using var sys = ActorSystem.Create("test", actorSystemSetup);
127+
128+
// Verify that DotNettyTransportSettings.Create uses the setup correctly
129+
var settings = DotNettyTransportSettings.Create(sys);
130+
131+
Assert.True(settings.EnableSsl);
132+
Assert.Equal(certificate, settings.Ssl.Certificate);
133+
Assert.True(settings.Ssl.SuppressValidation);
134+
Assert.True(settings.Ssl.RequireMutualAuthentication); // default from 2-param constructor
135+
Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 2-param constructor
136+
}
137+
138+
[Fact(DisplayName = "DotNettySslSetup with 3 parameters should configure effective DotNettyTransportSettings with specified RequireMutualAuth and default ValidateHostname=false")]
139+
public void Three_parameter_setup_should_configure_transport_settings()
140+
{
141+
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
142+
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: false, requireMutualAuthentication: false);
143+
144+
var actorSystemSetup = ActorSystemSetup.Empty
145+
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
146+
akka {
147+
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
148+
remote.dot-netty.tcp {
149+
port = 0
150+
hostname = ""127.0.0.1""
151+
enable-ssl = true
152+
}
153+
}")))
154+
.And(sslSetup);
155+
156+
using var sys = ActorSystem.Create("test", actorSystemSetup);
157+
158+
// Verify that DotNettyTransportSettings.Create uses the setup correctly
159+
var settings = DotNettyTransportSettings.Create(sys);
160+
161+
Assert.True(settings.EnableSsl);
162+
Assert.Equal(certificate, settings.Ssl.Certificate);
163+
Assert.False(settings.Ssl.SuppressValidation);
164+
Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false
165+
Assert.False(settings.Ssl.ValidateCertificateHostname); // default from 3-param constructor
166+
}
167+
168+
[Fact(DisplayName = "DotNettySslSetup with 4 parameters should configure effective DotNettyTransportSettings with all specified values")]
169+
public void Four_parameter_setup_should_configure_transport_settings_with_all_values()
170+
{
171+
var certificate = new X509Certificate2(ValidCertPath, Password, X509KeyStorageFlags.DefaultKeySet);
172+
var sslSetup = new DotNettySslSetup(certificate, suppressValidation: true, requireMutualAuthentication: false, validateCertificateHostname: true);
173+
174+
var actorSystemSetup = ActorSystemSetup.Empty
175+
.And(BootstrapSetup.Create().WithConfig(ConfigurationFactory.ParseString(@"
176+
akka {
177+
actor.provider = ""Akka.Remote.RemoteActorRefProvider,Akka.Remote""
178+
remote.dot-netty.tcp {
179+
port = 0
180+
hostname = ""127.0.0.1""
181+
enable-ssl = true
182+
}
183+
}")))
184+
.And(sslSetup);
185+
186+
using var sys = ActorSystem.Create("test", actorSystemSetup);
187+
188+
// Verify that DotNettyTransportSettings.Create uses the setup correctly
189+
var settings = DotNettyTransportSettings.Create(sys);
190+
191+
Assert.True(settings.EnableSsl);
192+
Assert.Equal(certificate, settings.Ssl.Certificate);
193+
Assert.True(settings.Ssl.SuppressValidation);
194+
Assert.False(settings.Ssl.RequireMutualAuthentication); // explicitly set to false
195+
Assert.True(settings.Ssl.ValidateCertificateHostname); // explicitly set to true
196+
}
197+
108198
#region helper classes / methods
109199

110200
protected override void AfterAll()

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,18 @@ akka {
565565
# Set to false only if your environment cannot support client certificate authentication.
566566
# Default: true (secure by default)
567567
require-mutual-authentication = true
568+
569+
# Enable or disable certificate hostname validation during TLS handshake.
570+
# When true: Traditional TLS hostname validation is performed (certificate CN/SAN must match target hostname)
571+
# When false: Only validates certificate chain against CA, ignores hostname mismatches
572+
#
573+
# Set to false for scenarios such as:
574+
# - Mutual TLS with per-node certificates in P2P clusters
575+
# - IP-based connections where certificates use DNS names
576+
# - Service discovery with dynamic addresses
577+
#
578+
# Default: false (disabled for backward compatibility and mutual TLS flexibility)
579+
validate-certificate-hostname = false
568580
}
569581
}
570582

0 commit comments

Comments
 (0)