smtp: enable TLS peer verification, fix envelope injection, fix dot-stuffing

This commit is contained in:
Pontoporeia
2026-04-30 12:36:15 +02:00
parent 33987c9b15
commit 8d115dc965
2 changed files with 77 additions and 11 deletions

View File

@@ -63,6 +63,14 @@
- [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636) - [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636)
- [ ] See `docs/LDAP_AUTH_PLAN.md` for full phase-by-phase plan - [ ] See `docs/LDAP_AUTH_PLAN.md` for full phase-by-phase plan
## SMTP transport security hardening
- [x] Enable TLS peer verification (`verify_peer`, `verify_peer_name`, `peer_name`) on both `smtpSend` and `smtpProbe` — removes MITM vulnerability from `verify_peer: false`
- [x] Add `caBundlePath()` — resolves system CA bundle path (php.ini → Debian/RHEL/Alpine candidates → PHP built-in fallback)
- [x] Set SSL context options explicitly on socket before `stream_socket_enable_crypto()` for STARTTLS (both probe and send paths)
- [x] Add `sanitiseEnvelope()` — strips CR/LF from envelope addresses to prevent SMTP command injection
- [x] Fix RFC 5321 §4.5.2 dot-stuffing: replace `preg_replace` with correct CRLF-normalise → `str_replace("\r\n.", "\r\n..")` sequence
## SMTP notify_email fix ## SMTP notify_email fix
- [x] Migration 006: add `notify_email` column to `smtp_settings` - [x] Migration 006: add `notify_email` column to `smtp_settings`

View File

@@ -164,9 +164,10 @@ class SmtpRelay {
$errno = 0; $errstr = ''; $errno = 0; $errstr = '';
$ctx = stream_context_create([ $ctx = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => false, 'verify_peer' => true,
'verify_peer_name' => false, 'verify_peer_name' => true,
'allow_self_signed' => true, 'peer_name' => $host,
'cafile' => self::caBundlePath(),
], ],
]); ]);
$sock = @stream_socket_client( $sock = @stream_socket_client(
@@ -259,9 +260,13 @@ class SmtpRelay {
'smtp_encryption' 'smtp_encryption'
); );
} }
stream_context_set_option($sock, 'ssl', 'peer_name', $host);
stream_context_set_option($sock, 'ssl', 'verify_peer', true);
stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new SmtpProbeException( throw new SmtpProbeException(
"Échec de la négociation TLS — essayez SSL (port 465) ou vérifiez le port.", "Échec de la négociation TLS — certificat invalide ou port incorrect ?",
'smtp_encryption' 'smtp_encryption'
); );
} }
@@ -395,6 +400,21 @@ class SmtpRelay {
/** /**
* Low-level native SMTP socket client (full send path). * Low-level native SMTP socket client (full send path).
*/ */
/**
* Sanitise an e-mail address for use in SMTP envelope commands.
* Strips everything except the angle-bracket address to prevent
* SMTP command injection via embedded CR/LF.
*/
private static function sanitiseEnvelope(string $addr): string
{
// Extract bare address if wrapped in display-name <addr> form
if (preg_match('/<([^>\r\n]+)>/', $addr, $m)) {
$addr = $m[1];
}
// Remove any CR or LF characters
return str_replace(["\r", "\n"], '', trim($addr));
}
private static function smtpSend(array $s, string $to, string $rawMessage): bool private static function smtpSend(array $s, string $to, string $rawMessage): bool
{ {
$host = $s['host']; $host = $s['host'];
@@ -402,14 +422,19 @@ class SmtpRelay {
$encryption = $s['encryption']; $encryption = $s['encryption'];
$timeout = 15; $timeout = 15;
// Sanitise envelope addresses
$envFrom = self::sanitiseEnvelope($s['from_email']);
$envTo = self::sanitiseEnvelope($to);
$connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host;
$errno = 0; $errstr = ''; $errno = 0; $errstr = '';
$ctx = stream_context_create([ $ctx = stream_context_create([
'ssl' => [ 'ssl' => [
'verify_peer' => false, 'verify_peer' => true,
'verify_peer_name' => false, 'verify_peer_name' => true,
'allow_self_signed' => true, 'peer_name' => $host,
'cafile' => self::caBundlePath(),
], ],
]); ]);
$sock = @stream_socket_client( $sock = @stream_socket_client(
@@ -467,8 +492,12 @@ class SmtpRelay {
if ($encryption === 'tls') { if ($encryption === 'tls') {
$expect('STARTTLS', '220'); $expect('STARTTLS', '220');
stream_context_set_option($sock, 'ssl', 'peer_name', $host);
stream_context_set_option($sock, 'ssl', 'verify_peer', true);
stream_context_set_option($sock, 'ssl', 'verify_peer_name', true);
stream_context_set_option($sock, 'ssl', 'cafile', self::caBundlePath());
if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { if (!stream_socket_enable_crypto($sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed'); throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)');
} }
$ehloResp = $send('EHLO ' . gethostname()); $ehloResp = $send('EHLO ' . gethostname());
$caps = $parseEhlo($ehloResp); $caps = $parseEhlo($ehloResp);
@@ -490,11 +519,14 @@ class SmtpRelay {
} }
} }
$expect("MAIL FROM:<{$s['from_email']}>", '250'); $expect("MAIL FROM:<{$envFrom}>", '250');
$expect("RCPT TO:<{$to}>", '250'); $expect("RCPT TO:<{$envTo}>", '250');
$expect('DATA', '354'); $expect('DATA', '354');
$stuffed = preg_replace('/^\./m', '..', $rawMessage); // RFC 5321 §4.5.2 dot-stuffing: prepend extra '.' to any line starting with '.'
$normalised = str_replace("\r\n", "\n", $rawMessage);
$normalised = str_replace("\n", "\r\n", $normalised);
$stuffed = str_replace("\r\n.", "\r\n..", $normalised);
$resp = $send($stuffed . "\r\n."); $resp = $send($stuffed . "\r\n.");
if (strncmp($resp, '250', 3) !== 0) { if (strncmp($resp, '250', 3) !== 0) {
throw new \RuntimeException("SMTP DATA rejected: {$resp}"); throw new \RuntimeException("SMTP DATA rejected: {$resp}");
@@ -527,6 +559,32 @@ class SmtpRelay {
// Internal // Internal
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/**
* Locate the system CA bundle for TLS peer verification.
* PHP uses its compiled-in default when openssl.cafile is set in php.ini;
* this provides an explicit path as a fallback for environments where
* the INI value is missing.
*/
private static function caBundlePath(): ?string
{
// Honour php.ini if set
$ini = ini_get('openssl.cafile');
if ($ini && file_exists($ini)) {
return $ini;
}
// Common system locations (Debian/Ubuntu, RHEL/CentOS, Alpine)
$candidates = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/ca-bundle.pem',
'/usr/local/share/certs/ca-root-nss.crt',
];
foreach ($candidates as $path) {
if (file_exists($path)) return $path;
}
return null; // PHP will fall back to its compiled-in bundle
}
private static function htmlToPlain(string $html): string { private static function htmlToPlain(string $html): string {
$text = strip_tags($html); $text = strip_tags($html);
$text = preg_replace('/\n{3,}/', "\n\n", $text); $text = preg_replace('/\n{3,}/', "\n\n", $text);