diff --git a/TODO.md b/TODO.md index ef5b290..3710017 100644 --- a/TODO.md +++ b/TODO.md @@ -63,6 +63,14 @@ - [ ] Verify TCP reachability from XAMXAM VM to LDAP server (port 636) - [ ] 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 - [x] Migration 006: add `notify_email` column to `smtp_settings` diff --git a/app/src/SmtpRelay.php b/app/src/SmtpRelay.php index 9ad4182..2dbb2b1 100644 --- a/app/src/SmtpRelay.php +++ b/app/src/SmtpRelay.php @@ -164,9 +164,10 @@ class SmtpRelay { $errno = 0; $errstr = ''; $ctx = stream_context_create([ 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true, + 'verify_peer' => true, + 'verify_peer_name' => true, + 'peer_name' => $host, + 'cafile' => self::caBundlePath(), ], ]); $sock = @stream_socket_client( @@ -259,9 +260,13 @@ class SmtpRelay { '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)) { 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' ); } @@ -395,6 +400,21 @@ class SmtpRelay { /** * 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 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 { $host = $s['host']; @@ -402,14 +422,19 @@ class SmtpRelay { $encryption = $s['encryption']; $timeout = 15; + // Sanitise envelope addresses + $envFrom = self::sanitiseEnvelope($s['from_email']); + $envTo = self::sanitiseEnvelope($to); + $connectHost = ($encryption === 'ssl') ? "ssl://{$host}" : $host; $errno = 0; $errstr = ''; $ctx = stream_context_create([ 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - 'allow_self_signed' => true, + 'verify_peer' => true, + 'verify_peer_name' => true, + 'peer_name' => $host, + 'cafile' => self::caBundlePath(), ], ]); $sock = @stream_socket_client( @@ -467,8 +492,12 @@ class SmtpRelay { if ($encryption === 'tls') { $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)) { - throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed'); + throw new \RuntimeException('SMTP STARTTLS crypto negotiation failed (invalid certificate?)'); } $ehloResp = $send('EHLO ' . gethostname()); $caps = $parseEhlo($ehloResp); @@ -490,11 +519,14 @@ class SmtpRelay { } } - $expect("MAIL FROM:<{$s['from_email']}>", '250'); - $expect("RCPT TO:<{$to}>", '250'); + $expect("MAIL FROM:<{$envFrom}>", '250'); + $expect("RCPT TO:<{$envTo}>", '250'); $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."); if (strncmp($resp, '250', 3) !== 0) { throw new \RuntimeException("SMTP DATA rejected: {$resp}"); @@ -527,6 +559,32 @@ class SmtpRelay { // 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 { $text = strip_tags($html); $text = preg_replace('/\n{3,}/', "\n\n", $text);