Skip to content

Commit 0c11573

Browse files
TaiizorTheDeadCodeclaude
committed
Fix #56: correct relay TLS negotiation (STARTTLS + opportunistic fallback)
The relay client forced implicit TLS even when TLS was not required, so relaying to a plain-text server (e.g. Mailpit) failed with "Cannot determine the frame size or a corrupted frame was received". Root causes: - RelayService.GetTargetConfiguration discarded the configured smart host (DefaultSmartHost/DomainRouting) and fabricated a config with UseTls = EnableTls (default true), forcing implicit TLS. - SmtpRelayClient only supported implicit TLS (SMTPS); it never issued STARTTLS and ignored UseStartTls/RequireTls. - The builder helpers mapped port 587 to implicit TLS (UseTls=true) instead of STARTTLS. Changes: - SmtpRelayClient: add UseStartTls/RequireTls/ValidateServerCertificate; implement opportunistic STARTTLS after EHLO with graceful plain-text fallback when not required, and enforcement when RequireTls is set. Honor the cancellation token during the TLS handshake. Restrict the "accept any certificate" shortcut to opportunistic STARTTLS only. - RelayService.GetTargetConfiguration: match host:port against DefaultSmartHost, SmartHosts and DomainRouting before fabricating an MX default (implicit TLS only on port 465). Wire the new client options. Remove the buggy EnableSsl override on the bounce path. - RelayBuilder/SmtpServerExtensions: map port 465 to implicit TLS and 587/others to STARTTLS. Co-authored-by: Felix Lebel <1714641+TheDeadCode@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43decd4 commit 0c11573

4 files changed

Lines changed: 163 additions & 29 deletions

File tree

src/Zetian.Relay/Builder/RelayBuilder.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ public RelayBuilder WithSmartHost(
4141
Credentials = !string.IsNullOrEmpty(username)
4242
? new NetworkCredential(username, password)
4343
: null,
44-
UseTls = port is 465 or 587,
45-
UseStartTls = port == 587
44+
UseTls = port == 465,
45+
UseStartTls = port != 465
4646
};
4747
return this;
4848
}
@@ -194,8 +194,8 @@ public RelayBuilder AddDomainRoute(
194194
Credentials = !string.IsNullOrEmpty(username)
195195
? new NetworkCredential(username, password)
196196
: null,
197-
UseTls = port is 465 or 587,
198-
UseStartTls = port == 587
197+
UseTls = port == 465,
198+
UseStartTls = port != 465
199199
};
200200
return this;
201201
}

src/Zetian.Relay/Client/SmtpRelayClient.cs

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,33 @@ public class SmtpRelayClient(ILogger<SmtpRelayClient>? logger = null) : ISmtpCli
3434

3535
public string Host { get; set; } = "localhost";
3636
public int Port { get; set; } = 25;
37+
38+
/// <summary>
39+
/// Gets or sets whether to use implicit TLS (SMTPS) by negotiating TLS immediately on connect.
40+
/// Typically used for port 465. For STARTTLS use <see cref="UseStartTls"/> instead.
41+
/// </summary>
3742
public bool EnableSsl { get; set; }
43+
44+
/// <summary>
45+
/// Gets or sets whether to attempt opportunistic STARTTLS after EHLO when the server advertises it.
46+
/// Ignored when <see cref="EnableSsl"/> (implicit TLS) is enabled.
47+
/// </summary>
48+
public bool UseStartTls { get; set; } = true;
49+
50+
/// <summary>
51+
/// Gets or sets whether TLS is mandatory. When true, the connection fails unless TLS is
52+
/// established (via implicit TLS or STARTTLS). When false, TLS is opportunistic and the
53+
/// client falls back to plain text if the server does not offer it.
54+
/// </summary>
55+
public bool RequireTls { get; set; }
56+
57+
/// <summary>
58+
/// Gets or sets whether to validate the server certificate when TLS is required.
59+
/// For opportunistic TLS (when <see cref="RequireTls"/> is false) certificate errors are
60+
/// ignored, because encryption without authentication is still preferable to plain text.
61+
/// </summary>
62+
public bool ValidateServerCertificate { get; set; } = true;
63+
3864
public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls12 | SslProtocols.Tls13;
3965
public X509Certificate2? ClientCertificate { get; set; }
4066
public NetworkCredential? Credentials { get; set; }
@@ -62,10 +88,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
6288
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
6389
cts.CancelAfter(Timeout);
6490

65-
await _tcpClient.ConnectAsync(Host, Port).ConfigureAwait(false);
91+
await _tcpClient.ConnectAsync(Host, Port, cts.Token).ConfigureAwait(false);
6692

6793
_stream = _tcpClient.GetStream();
6894

95+
// Implicit TLS (SMTPS, e.g. port 465): negotiate TLS before the greeting.
6996
if (EnableSsl)
7097
{
7198
await UpgradeToSslAsync(cts.Token).ConfigureAwait(false);
@@ -84,6 +111,28 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
84111
// Send EHLO
85112
await SendEhloAsync(cts.Token).ConfigureAwait(false);
86113

114+
// Opportunistic / required STARTTLS upgrade (only when not already using implicit TLS).
115+
if (!EnableSsl && (UseStartTls || RequireTls))
116+
{
117+
bool serverSupportsStartTls = _serverCapabilities?.ContainsKey("STARTTLS") == true;
118+
119+
if (serverSupportsStartTls)
120+
{
121+
await StartTlsAsync(cts.Token).ConfigureAwait(false);
122+
}
123+
else if (RequireTls)
124+
{
125+
throw new InvalidOperationException(
126+
$"TLS is required but server {Host}:{Port} does not advertise STARTTLS");
127+
}
128+
else
129+
{
130+
_logger.LogWarning(
131+
"Server {Host}:{Port} does not support STARTTLS; continuing without encryption",
132+
Host, Port);
133+
}
134+
}
135+
87136
_logger.LogInformation("Connected to {Host}:{Port}", Host, Port);
88137
}
89138
catch (Exception ex)
@@ -386,16 +435,58 @@ private async Task SendEhloAsync(CancellationToken cancellationToken)
386435

387436
private async Task UpgradeToSslAsync(CancellationToken cancellationToken)
388437
{
389-
SslStream sslStream = new(_stream, false, ValidateServerCertificate);
438+
SslStream sslStream = new(_stream!, false, OnCertificateValidation);
439+
440+
SslClientAuthenticationOptions options = new()
441+
{
442+
TargetHost = Host,
443+
EnabledSslProtocols = SslProtocols,
444+
CertificateRevocationCheckMode = X509RevocationMode.Online
445+
};
390446

391-
await sslStream.AuthenticateAsClientAsync(
392-
Host,
393-
ClientCertificate != null ? [ClientCertificate] : null,
394-
SslProtocols,
395-
true).ConfigureAwait(false);
447+
if (ClientCertificate != null)
448+
{
449+
options.ClientCertificates = new X509CertificateCollection { ClientCertificate };
450+
}
451+
452+
// Pass the cancellation token so a stalled handshake honors the connection timeout.
453+
await sslStream.AuthenticateAsClientAsync(options, cancellationToken).ConfigureAwait(false);
396454

397455
_stream = sslStream;
398-
_logger.LogDebug("SSL/TLS connection established");
456+
_logger.LogDebug("SSL/TLS connection established with {Host}:{Port}", Host, Port);
457+
}
458+
459+
private async Task StartTlsAsync(CancellationToken cancellationToken)
460+
{
461+
await SendCommandAsync("STARTTLS", cancellationToken).ConfigureAwait(false);
462+
SmtpResponse response = await ReadResponseAsync(cancellationToken).ConfigureAwait(false);
463+
464+
// RFC 3207: a 220 reply means the server is ready to negotiate TLS.
465+
if (response.Code != 220)
466+
{
467+
if (RequireTls)
468+
{
469+
throw new InvalidOperationException(
470+
$"STARTTLS command rejected by {Host}:{Port}: {response.Code} {response.Message}");
471+
}
472+
473+
_logger.LogWarning(
474+
"STARTTLS rejected by {Host}:{Port} ({Code} {Message}); continuing without encryption",
475+
Host, Port, response.Code, response.Message);
476+
return;
477+
}
478+
479+
await UpgradeToSslAsync(cancellationToken).ConfigureAwait(false);
480+
481+
// The reader/writer must be rebuilt on top of the now-encrypted stream. The old ones
482+
// are intentionally not disposed (that would close the underlying socket). Per RFC 3207
483+
// the server must not transmit anything after its "220" until the TLS handshake, so the
484+
// discarded plain-text reader cannot have buffered any post-handshake bytes.
485+
_reader = new StreamReader(_stream!, Encoding.ASCII);
486+
_writer = new StreamWriter(_stream!, Encoding.ASCII) { AutoFlush = true };
487+
488+
// RFC 3207: the client must re-issue EHLO over the secured channel.
489+
await SendEhloAsync(cancellationToken).ConfigureAwait(false);
399490
}
400491

401492
private async Task AuthPlainAsync(CancellationToken cancellationToken)
@@ -548,7 +639,7 @@ private async Task<SmtpResponse> ReadMultilineResponseAsync(CancellationToken ca
548639
return new SmtpResponse(code, [.. lines]);
549640
}
550641

551-
private bool ValidateServerCertificate(
642+
private bool OnCertificateValidation(
552643
object sender,
553644
X509Certificate? certificate,
554645
X509Chain? chain,
@@ -559,9 +650,31 @@ private bool ValidateServerCertificate(
559650
return true;
560651
}
561652

562-
_logger.LogWarning("SSL certificate validation error: {Errors}", sslPolicyErrors);
653+
// Opportunistic STARTTLS only: when we upgraded an otherwise plain-text connection
654+
// and TLS is not required, encryption without authentication is still better than
655+
// falling back to plain text, so accept the certificate but warn. This must NOT apply
656+
// to implicit TLS (EnableSsl), where there is no plain-text fallback and silently
657+
// accepting any certificate would mask a man-in-the-middle.
658+
if (!EnableSsl && !RequireTls)
659+
{
660+
_logger.LogWarning(
661+
"Ignoring SSL certificate error ({Errors}) for opportunistic TLS to {Host}:{Port}",
662+
sslPolicyErrors, Host, Port);
663+
return true;
664+
}
665+
666+
// Implicit TLS or required TLS: honor the configured certificate validation policy.
667+
if (!ValidateServerCertificate)
668+
{
669+
_logger.LogWarning(
670+
"SSL certificate error ({Errors}) ignored by configuration for {Host}:{Port}",
671+
sslPolicyErrors, Host, Port);
672+
return true;
673+
}
563674

564-
// You might want to make this configurable
675+
_logger.LogWarning(
676+
"SSL certificate validation failed ({Errors}) for {Host}:{Port}",
677+
sslPolicyErrors, Host, Port);
565678
return false;
566679
}
567680

src/Zetian.Relay/Extensions/SmtpServerExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ public static ISmtpServer AddSmartHost(
8181
Host = host,
8282
Port = port,
8383
Credentials = credentials,
84-
UseTls = port is 465 or 587,
85-
UseStartTls = port == 587
84+
UseTls = port == 465,
85+
UseStartTls = port != 465
8686
};
8787

8888
// This is a simplified approach - in production you'd modify the configuration

src/Zetian.Relay/Services/RelayService.cs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -417,21 +417,43 @@ private async Task<bool> CanRelayAsync(ISmtpSession session, ISmtpMessage messag
417417
string host = parts[0];
418418
int port = parts.Length > 1 && int.TryParse(parts[1], out int p) ? p : 25;
419419

420-
// Find matching configuration
420+
// Prefer an explicitly configured smart host so its TLS, credential and timeout
421+
// settings are honored instead of being overwritten by global defaults.
422+
if (Configuration.DefaultSmartHost != null &&
423+
string.Equals(Configuration.DefaultSmartHost.Host, host, StringComparison.OrdinalIgnoreCase) &&
424+
Configuration.DefaultSmartHost.Port == port)
425+
{
426+
return Configuration.DefaultSmartHost;
427+
}
428+
429+
// Find matching failover smart host
421430
SmartHostConfiguration? config = Configuration.SmartHosts
422-
.FirstOrDefault(sh => sh.Host == host && sh.Port == port && sh.Enabled);
431+
.FirstOrDefault(sh => string.Equals(sh.Host, host, StringComparison.OrdinalIgnoreCase) &&
432+
sh.Port == port && sh.Enabled);
433+
434+
if (config != null)
435+
{
436+
return config;
437+
}
438+
439+
// Find matching domain-routing smart host
440+
config = Configuration.DomainRouting.Values
441+
.FirstOrDefault(sh => string.Equals(sh.Host, host, StringComparison.OrdinalIgnoreCase) &&
442+
sh.Port == port);
423443

424444
if (config != null)
425445
{
426446
return config;
427447
}
428448

429-
// Create default configuration
449+
// No explicit configuration (e.g. an MX-routed host): build a sensible default.
450+
// Use implicit TLS only for the SMTPS port (465); otherwise rely on opportunistic
451+
// STARTTLS so delivery falls back to plain text when the server offers no TLS.
430452
return new SmartHostConfiguration
431453
{
432454
Host = host,
433455
Port = port,
434-
UseTls = Configuration.EnableTls,
456+
UseTls = port == 465,
435457
UseStartTls = Configuration.EnableTls,
436458
ConnectionTimeout = Configuration.ConnectionTimeout
437459
};
@@ -450,7 +472,11 @@ private ISmtpClient GetOrCreateClient(SmartHostConfiguration config)
450472
{
451473
Host = config.Host,
452474
Port = config.Port,
453-
EnableSsl = config.UseTls,
475+
EnableSsl = config.UseTls, // implicit TLS (SMTPS)
476+
UseStartTls = config.UseStartTls, // opportunistic STARTTLS
477+
RequireTls = Configuration.RequireTls, // TLS requirement policy
478+
SslProtocols = Configuration.SslProtocols,
479+
ValidateServerCertificate = Configuration.ValidateServerCertificate,
454480
Credentials = config.Credentials,
455481
LocalDomain = Configuration.LocalDomain,
456482
Timeout = config.ConnectionTimeout
@@ -523,15 +549,10 @@ private async Task SendBounceMessageAsync(IRelayMessage message, string error)
523549
{
524550
ISmtpClient client = GetOrCreateClient(Configuration.DefaultSmartHost);
525551

526-
// Connect if not connected
552+
// Connect if not connected. The client is already configured with the smart
553+
// host's TLS, credential and timeout settings by GetOrCreateClient.
527554
if (!client.IsConnected)
528555
{
529-
// Set connection parameters
530-
client.Host = Configuration.DefaultSmartHost.Host;
531-
client.Port = Configuration.DefaultSmartHost.Port;
532-
client.EnableSsl = Configuration.DefaultSmartHost.UseTls || Configuration.DefaultSmartHost.UseStartTls;
533-
client.Credentials = Configuration.DefaultSmartHost.Credentials;
534-
535556
await client.ConnectAsync().ConfigureAwait(false);
536557

537558
// Authenticate if credentials provided

0 commit comments

Comments
 (0)