mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 11:09:18 +02:00
smtp: enable TLS peer verification, fix envelope injection, fix dot-stuffing
This commit is contained in:
8
TODO.md
8
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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user