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

@@ -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 <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
{
$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);