From ae87509595bbbf3ed5cfca54c7e80af98845ec61 Mon Sep 17 00:00:00 2001 From: Danelif Date: Sat, 11 Oct 2025 03:00:12 +0200 Subject: [PATCH] [NEW] Implement MTA-STS and TLS-RPT support for email security visibility --- lib/mta_sts.php | 434 +++++++++++++++++++++++++++++++++++++ modules/mta_sts/README.md | 131 +++++++++++ modules/mta_sts/module.php | 277 +++++++++++++++++++++++ modules/mta_sts/setup.php | 26 +++ tests/phpunit/mta_sts.php | 123 +++++++++++ 5 files changed, 991 insertions(+) create mode 100644 lib/mta_sts.php create mode 100644 modules/mta_sts/README.md create mode 100644 modules/mta_sts/module.php create mode 100644 modules/mta_sts/setup.php create mode 100644 tests/phpunit/mta_sts.php diff --git a/lib/mta_sts.php b/lib/mta_sts.php new file mode 100644 index 000000000..36afc1162 --- /dev/null +++ b/lib/mta_sts.php @@ -0,0 +1,434 @@ +domain = strtolower(trim($domain)); + } + } + + /** + * Set the domain to check + * @param string $domain The domain to check + * @return self + */ + public function set_domain($domain) { + $this->domain = strtolower(trim($domain)); + return $this; + } + + /** + * Check if the current domain has MTA-STS enabled + * + * @return array Array with 'enabled' (bool), 'policy' (array|null), and 'error' (string|null) + */ + public function check_domain() { + if (empty($this->domain)) { + return array( + 'enabled' => false, + 'policy' => null, + 'error' => 'No domain specified', + 'dns_record' => null + ); + } + + // Check cache first + if (isset($this->policy_cache[$this->domain])) { + $cached = $this->policy_cache[$this->domain]; + if (time() - $cached['timestamp'] < $this->cache_ttl) { + return $cached['result']; + } + } + + $result = array( + 'enabled' => false, + 'policy' => null, + 'error' => null, + 'dns_record' => null + ); + + // Step 1: Check for MTA-STS DNS TXT record + $dns_record = $this->get_mta_sts_dns_record(); + if ($dns_record === false) { + $result['error'] = 'No MTA-STS DNS record found'; + $this->cache_result($result); + return $result; + } + + $result['dns_record'] = $dns_record; + + // Step 2: Parse DNS record to get policy ID + $policy_id = $this->parse_policy_id($dns_record); + if (!$policy_id) { + $result['error'] = 'Invalid MTA-STS DNS record format'; + $this->cache_result($result); + return $result; + } + + // Step 3: Fetch and parse the policy file + $policy = $this->fetch_policy(); + if (!$policy) { + $result['error'] = 'Could not fetch MTA-STS policy file'; + $this->cache_result($result); + return $result; + } + + $result['enabled'] = true; + $result['policy'] = $policy; + + $this->cache_result($result); + return $result; + } + + /** + * Get MTA-STS DNS TXT record for the current domain + * + * @return string|false The DNS record or false if not found + */ + private function get_mta_sts_dns_record() { + $record_name = "_mta-sts.{$this->domain}"; + + // Try to get DNS TXT records + $records = @dns_get_record($record_name, DNS_TXT); + + if ($records === false || empty($records)) { + return false; + } + + // Look for the MTA-STS record (v=STSv1) + foreach ($records as $record) { + if (isset($record['txt']) && strpos($record['txt'], 'v=STSv1') !== false) { + return $record['txt']; + } + } + + return false; + } + + /** + * Parse policy ID from MTA-STS DNS record + * + * @param string $dns_record The DNS TXT record + * @return string|false The policy ID or false if not found + */ + private function parse_policy_id($dns_record) { + // Expected format: "v=STSv1; id=20190429T010101;" + if (preg_match('/id=([^;]+);?/i', $dns_record, $matches)) { + return trim($matches[1]); + } + return false; + } + + /** + * Fetch MTA-STS policy file from the current domain + * + * @return array|false Parsed policy or false on failure + */ + private function fetch_policy() { + $policy_url = "https://mta-sts.{$this->domain}/.well-known/mta-sts.txt"; + + // Use Hm_Functions curl wrappers if available, otherwise fall back to file_get_contents + $ch = Hm_Functions::c_init(); + if ($ch) { + $policy_content = $this->fetch_with_curl($ch, $policy_url); + } else { + $policy_content = $this->fetch_with_fopen($policy_url); + } + + if (!$policy_content) { + return false; + } + + return $this->parse_policy($policy_content); + } + + /** + * Fetch policy using cURL via Hm_Functions wrappers + * + * @param resource $ch curl handle from Hm_Functions::c_init() + * @param string $url The URL to fetch + * @return string|false The content or false on failure + */ + private function fetch_with_curl($ch, $url) { + Hm_Functions::c_setopt($ch, CURLOPT_URL, $url); + Hm_Functions::c_setopt($ch, CURLOPT_RETURNTRANSFER, true); + Hm_Functions::c_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + Hm_Functions::c_setopt($ch, CURLOPT_TIMEOUT, 10); + Hm_Functions::c_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + Hm_Functions::c_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); + + $content = Hm_Functions::c_exec($ch); + $http_code = Hm_Functions::c_status($ch); + + if ($http_code === 200 && $content !== false) { + return $content; + } + + return false; + } + + /** + * Fetch policy using file_get_contents as fallback + * + * @param string $url The URL to fetch + * @return string|false The content or false on failure + */ + private function fetch_with_fopen($url) { + $context = stream_context_create(array( + 'http' => array( + 'timeout' => 10, + 'follow_location' => 1 + ), + 'ssl' => array( + 'verify_peer' => true, + 'verify_peer_name' => true + ) + )); + + $content = @file_get_contents($url, false, $context); + + return ($content !== false) ? $content : false; + } + + /** + * Parse MTA-STS policy content + * + * @param string $content The policy file content + * @return array|false Parsed policy or false on failure + */ + private function parse_policy($content) { + $policy = array( + 'version' => null, + 'mode' => null, + 'mx' => array(), + 'max_age' => null + ); + + $lines = explode("\n", $content); + + foreach ($lines as $line) { + $line = trim($line); + + // Skip empty lines and comments + if (empty($line) || $line[0] === '#') { + continue; + } + + // Parse key: value format + $parts = explode(':', $line, 2); + if (count($parts) !== 2) { + continue; + } + + $key = trim($parts[0]); + $value = trim($parts[1]); + + switch ($key) { + case 'version': + $policy['version'] = $value; + break; + case 'mode': + $policy['mode'] = $value; + break; + case 'mx': + $policy['mx'][] = $value; + break; + case 'max_age': + $policy['max_age'] = intval($value); + break; + } + } + + // Validate required fields + if ($policy['version'] !== 'STSv1' || empty($policy['mode']) || empty($policy['mx'])) { + return false; + } + + return $policy; + } + + /** + * Cache a lookup result for the current domain + * + * @param array $result The result to cache + */ + private function cache_result($result) { + $this->policy_cache[$this->domain] = array( + 'timestamp' => time(), + 'result' => $result + ); + } + + /** + * Check if the current domain has TLS-RPT enabled + * + * @return array Array with 'enabled' (bool), 'rua' (string|null), and 'error' (string|null) + */ + public function check_tls_rpt() { + if (empty($this->domain)) { + return array( + 'enabled' => false, + 'rua' => null, + 'error' => 'No domain specified' + ); + } + + $record_name = "_smtp._tls.{$this->domain}"; + + // Try to get DNS TXT records + $records = @dns_get_record($record_name, DNS_TXT); + + if ($records === false || empty($records)) { + return array( + 'enabled' => false, + 'rua' => null, + 'error' => 'No TLS-RPT DNS record found' + ); + } + + // Look for the TLS-RPT record (v=TLSRPTv1) + foreach ($records as $record) { + if (isset($record['txt']) && strpos($record['txt'], 'v=TLSRPTv1') !== false) { + $rua = $this->parse_tls_rpt_rua($record['txt']); + return array( + 'enabled' => true, + 'rua' => $rua, + 'error' => null, + 'dns_record' => $record['txt'] + ); + } + } + + return array( + 'enabled' => false, + 'rua' => null, + 'error' => 'No valid TLS-RPT record found' + ); + } + + /** + * Parse RUA (Reporting URI of Aggregate reports) from TLS-RPT DNS record + * + * @param string $dns_record The DNS TXT record + * @return string|null The RUA or null if not found + */ + private function parse_tls_rpt_rua($dns_record) { + // Expected format: "v=TLSRPTv1; rua=mailto:reports@example.com" + if (preg_match('/rua=([^;]+);?/i', $dns_record, $matches)) { + return trim($matches[1]); + } + return null; + } + + /** + * Extract domain from email address + * + * @param string $email The email address + * @return string|false The domain or false if invalid + */ + public static function extract_domain($email) { + $email = trim($email); + + // Remove display name if present (e.g., "John Doe ") + if (preg_match('/<([^>]+)>/', $email, $matches)) { + $email = $matches[1]; + } + + // Extract domain part + $parts = explode('@', $email); + if (count($parts) === 2) { + return strtolower(trim($parts[1])); + } + + return false; + } + + /** + * Get a human-readable status message for MTA-STS check + * + * @param array $mta_sts_result Result from check_domain() + * @return string Status message + */ + public static function get_status_message($mta_sts_result) { + if ($mta_sts_result['enabled']) { + $mode = isset($mta_sts_result['policy']['mode']) ? $mta_sts_result['policy']['mode'] : 'unknown'; + + switch ($mode) { + case 'enforce': + return 'MTA-STS enabled (enforce mode) - TLS required'; + case 'testing': + return 'MTA-STS enabled (testing mode) - TLS preferred'; + case 'none': + return 'MTA-STS disabled'; + default: + return 'MTA-STS enabled (unknown mode)'; + } + } + + return 'MTA-STS not configured'; + } + + /** + * Get CSS class for status indicator + * + * @param array $mta_sts_result Result from check_domain() + * @return string CSS class name + */ + public static function get_status_class($mta_sts_result) { + if (!$mta_sts_result['enabled']) { + return 'mta-sts-disabled'; + } + + $mode = isset($mta_sts_result['policy']['mode']) ? $mta_sts_result['policy']['mode'] : 'none'; + + switch ($mode) { + case 'enforce': + return 'mta-sts-enforce'; + case 'testing': + return 'mta-sts-testing'; + default: + return 'mta-sts-disabled'; + } + } + + /** + * Clear the policy cache + */ + public function clear_cache() { + $this->policy_cache = array(); + } +} diff --git a/modules/mta_sts/README.md b/modules/mta_sts/README.md new file mode 100644 index 000000000..cab6fb927 --- /dev/null +++ b/modules/mta_sts/README.md @@ -0,0 +1,131 @@ +# MTA-STS Module + +This module provides MTA-STS (Mail Transfer Agent Strict Transport Security) and TLS-RPT (TLS Reporting) support in Cypht. + +## Features + +- **Real-time MTA-STS checking**: Automatically checks if recipient domains support MTA-STS when composing emails +- **Visual status indicators**: Displays clear security status badges for each recipient +- **TLS-RPT detection**: Shows if domains have TLS reporting configured +- **Policy information**: Displays whether a domain enforces or prefers TLS encryption + +## What is MTA-STS? + +MTA-STS (RFC 8461) is a security standard that allows email domains to: +- Declare their ability to receive TLS-secured SMTP connections +- Require sending servers to refuse delivery if TLS is not available +- Protect against man-in-the-middle attacks on email delivery + +## What is TLS-RPT? + +TLS-RPT (RFC 8460) is a reporting mechanism that: +- Allows domains to receive reports about TLS connection failures +- Helps identify issues with email security +- Provides visibility into attempted attacks or misconfigurations + +## How It Works + +When you compose an email in Cypht, the module: + +1. Extracts recipient email addresses from the "To" field +2. Checks each recipient's domain for MTA-STS DNS records +3. Fetches and parses the MTA-STS policy (if available) +4. Checks for TLS-RPT configuration +5. Displays the security status with visual indicators + +## Status Indicators + +### MTA-STS Status + +- **Enforce Mode (Green)**: TLS encryption is required. Email will only be delivered over encrypted connections. +- **Testing Mode (Blue)**: TLS encryption is preferred but not required. Delivery will proceed even without TLS. +- **Not Configured (Yellow)**: Domain does not have MTA-STS configured. TLS security is not enforced. + +### TLS-RPT Status + +- **Enabled (Blue)**: Domain receives reports about TLS connection issues. + +## Installation + +The module is included with Cypht. To enable it: + +1. Ensure the main MTA-STS library is present: `lib/mta_sts.php` +2. The module is automatically loaded if present in `modules/mta_sts/` +3. No additional configuration is required + +## For Administrators + +To set up MTA-STS for your own email domain, use the included setup script: + +```bash +./scripts/setup_mta_sts.sh -d yourdomain.com -m "mail.yourdomain.com" -e security@yourdomain.com +``` + + + +## Dependencies + +- PHP with cURL or allow_url_fopen enabled +- DNS functions available (dns_get_record) +- Internet access to check external domains + +## Performance + +- Results are cached for 1 hour to minimize DNS lookups and HTTP requests +- Only active when composing emails +- Checks are performed server-side + +## Privacy + +- The module checks public DNS records (TXT records) +- Fetches publicly available policy files over HTTPS +- No recipient information is sent to third parties +- All checks are performed by your Cypht server + +## Troubleshooting + +### Status Not Showing + +- Ensure recipients are entered in the "To" field +- Check that DNS functions are available +- Verify internet connectivity from server + +### Incorrect Status + +- DNS records may be cached; clear the cache +- Policy files must be served over HTTPS with valid certificates +- Check domain configuration with validators + +## Technical Details + +### Files + +- `modules.php`: Handler and output classes +- `setup.php`: Module registration and hooks + +### Classes + +- `Hm_Handler_check_mta_sts_status`: Checks MTA-STS and TLS-RPT status for recipients +- `Hm_Output_mta_sts_status_indicator`: Displays security status in compose form +- `Hm_Output_mta_sts_styles`: Adds CSS styling for status indicators + +### Library + +The module uses `Hm_MTA_STS` class from `lib/mta_sts.php`: + +- `check_domain()`: Check if domain has MTA-STS enabled +- `check_tls_rpt()`: Check if domain has TLS-RPT enabled +- `extract_domain()`: Extract domain from email address +- `get_status_message()`: Get human-readable status message +- `get_status_class()`: Get CSS class for styling + +## References + +- [RFC 8461 - MTA-STS](https://tools.ietf.org/html/rfc8461) +- [RFC 8460 - TLS-RPT](https://tools.ietf.org/html/rfc8460) +- [MTA-STS Setup Guide](https://dmarcly.com/blog/how-to-set-up-mta-sts-and-tls-reporting) +- [Cypht Issue #337](https://github.com/cypht-org/cypht/issues/337) + +## License + +This module is part of Cypht and uses the same license. diff --git a/modules/mta_sts/module.php b/modules/mta_sts/module.php new file mode 100644 index 000000000..2a1309741 --- /dev/null +++ b/modules/mta_sts/module.php @@ -0,0 +1,277 @@ +get('compose_to', ''); + + if (empty($compose_to)) { + // Check if there are draft recipients + $draft = $this->get('compose_draft', array()); + if (!empty($draft) && isset($draft['draft_to'])) { + $compose_to = $draft['draft_to']; + } + } + + if (empty($compose_to)) { + // Check reply details + $reply = $this->get('reply_details', array()); + if (!empty($reply) && isset($reply['msg_headers']['From'])) { + $from = $reply['msg_headers']['From']; + if (is_array($from)) { + $from = implode(', ', $from); + } + $compose_to = $from; + } + } + + // Check if MTA-STS checking is enabled in user settings + $settings = $this->user_config->get('enable_mta_sts_check', false); + if (!$settings) { + return; + } + + if (!empty($compose_to)) { + $recipients = $this->parse_recipients($compose_to); + $mta_sts_status = array(); + + foreach ($recipients as $email) { + $domain = Hm_MTA_STS::extract_domain($email); + if ($domain) { + // Create an instance for this domain + $mta_sts = new Hm_MTA_STS($domain); + $result = $mta_sts->check_domain(); + $tls_rpt = $mta_sts->check_tls_rpt(); + + $mta_sts_status[$email] = array( + 'domain' => $domain, + 'mta_sts' => $result, + 'tls_rpt' => $tls_rpt, + 'status_message' => Hm_MTA_STS::get_status_message($result), + 'status_class' => Hm_MTA_STS::get_status_class($result) + ); + } + } + + $this->out('mta_sts_status', $mta_sts_status); + } + } + + /** + * Parse recipient string into individual email addresses + * @param string $recipients Comma-separated email addresses + * @return array Array of email addresses + */ + private function parse_recipients($recipients) { + $emails = array(); + + // Split by comma + $parts = explode(',', $recipients); + + foreach ($parts as $part) { + $part = trim($part); + if (empty($part)) { + continue; + } + + // Extract email from "Name " format + if (preg_match('/<([^>]+)>/', $part, $matches)) { + $emails[] = trim($matches[1]); + } else { + // Assume it's a plain email address + if (filter_var($part, FILTER_VALIDATE_EMAIL)) { + $emails[] = $part; + } + } + } + + return array_unique($emails); + } +} + +/** + * Output MTA-STS status indicator in compose form + * @subpackage mta_sts/output + */ +class Hm_Output_mta_sts_status_indicator extends Hm_Output_Module { + /** + * Display MTA-STS status for recipients + */ + protected function output() { + $mta_sts_status = $this->get('mta_sts_status', array()); + + if (empty($mta_sts_status)) { + return ''; + } + + $output = '
'; + $output .= '
'; + $output .= $this->html_safe($this->trans('Email Security Status')); + $output .= '
'; + + foreach ($mta_sts_status as $email => $status) { + $domain = $status['domain']; + $mta_sts = $status['mta_sts']; + $tls_rpt = $status['tls_rpt']; + $status_message = $status['status_message']; + $status_class = $status['status_class']; + + $output .= '
'; + $output .= '
' . $this->html_safe($email) . '
'; + $output .= '
Domain: ' . $this->html_safe($domain) . '
'; + + // MTA-STS status + $output .= '
'; + if ($mta_sts['enabled']) { + $mode = $mta_sts['policy']['mode']; + switch ($mode) { + case 'enforce': + $output .= '🔒 MTA-STS: Enforce Mode'; + $output .= 'TLS encryption required'; + break; + case 'testing': + $output .= '🔓 MTA-STS: Testing Mode'; + $output .= 'TLS encryption preferred'; + break; + case 'none': + $output .= 'MTA-STS: Disabled'; + break; + } + } else { + $output .= '⚠️ MTA-STS: Not Configured'; + $output .= 'TLS security not enforced'; + } + $output .= '
'; + + // TLS-RPT status + if ($tls_rpt['enabled']) { + $output .= '
'; + $output .= '📊 TLS-RPT: Enabled'; + $output .= 'Reports to: ' . $this->html_safe($tls_rpt['rua']) . ''; + $output .= '
'; + } + + $output .= '
'; + } + + $output .= '
'; + + return $output; + } +} + +/** + * Add CSS for MTA-STS indicators + * @subpackage mta_sts/output + */ +class Hm_Output_mta_sts_styles extends Hm_Output_Module { + /** + * Add custom CSS for MTA-STS status indicators + */ + protected function output() { + return ''; + } +} + +/** + * Process MTA-STS enable/disable setting + * @subpackage mta_sts/handler + */ +class Hm_Handler_process_enable_mta_sts_setting extends Hm_Handler_Module { + /** + * Process enable_mta_sts_check setting from the settings page + */ + public function process() { + process_site_setting('enable_mta_sts_check', $this, 'enable_mta_sts_check_callback', false, true); + } +} + +/** + * @subpackage mta_sts/functions + */ +if (!hm_exists('enable_mta_sts_check_callback')) { + function enable_mta_sts_check_callback($val) { + return $val; + } +} + +/** + * Output MTA-STS setting on the settings page + * @subpackage mta_sts/output + */ +class Hm_Output_enable_mta_sts_check_setting extends Hm_Output_Module { + /** + * Output the enable_mta_sts_check checkbox on the settings page + */ + protected function output() { + $settings = $this->get('user_settings', array()); + $checked = ''; + if (array_key_exists('enable_mta_sts_check', $settings) && $settings['enable_mta_sts_check']) { + $checked = ' checked="checked"'; + } + return ''. + ''. + ''; + } +} diff --git a/modules/mta_sts/setup.php b/modules/mta_sts/setup.php new file mode 100644 index 000000000..198174246 --- /dev/null +++ b/modules/mta_sts/setup.php @@ -0,0 +1,26 @@ +assertEquals('example.com', Hm_MTA_STS::extract_domain('user@example.com')); + $this->assertEquals('example.com', Hm_MTA_STS::extract_domain('User Name ')); + $this->assertEquals('example.com', Hm_MTA_STS::extract_domain(' user@example.com ')); + $this->assertEquals('sub.example.com', Hm_MTA_STS::extract_domain('user@sub.example.com')); + $this->assertFalse(Hm_MTA_STS::extract_domain('invalid-email')); + $this->assertFalse(Hm_MTA_STS::extract_domain('')); + } + + /** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ + public function test_get_status_message() { + // Test enforce mode + $result = array( + 'enabled' => true, + 'policy' => array('mode' => 'enforce') + ); + $this->assertEquals('MTA-STS enabled (enforce mode) - TLS required', Hm_MTA_STS::get_status_message($result)); + + // Test testing mode + $result = array( + 'enabled' => true, + 'policy' => array('mode' => 'testing') + ); + $this->assertEquals('MTA-STS enabled (testing mode) - TLS preferred', Hm_MTA_STS::get_status_message($result)); + + // Test none mode + $result = array( + 'enabled' => true, + 'policy' => array('mode' => 'none') + ); + $this->assertEquals('MTA-STS disabled', Hm_MTA_STS::get_status_message($result)); + + // Test disabled + $result = array( + 'enabled' => false, + 'policy' => null + ); + $this->assertEquals('MTA-STS not configured', Hm_MTA_STS::get_status_message($result)); + } + + /** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ + public function test_get_status_class() { + // Test enforce mode + $result = array( + 'enabled' => true, + 'policy' => array('mode' => 'enforce') + ); + $this->assertEquals('mta-sts-enforce', Hm_MTA_STS::get_status_class($result)); + + // Test testing mode + $result = array( + 'enabled' => true, + 'policy' => array('mode' => 'testing') + ); + $this->assertEquals('mta-sts-testing', Hm_MTA_STS::get_status_class($result)); + + // Test disabled + $result = array( + 'enabled' => false, + 'policy' => null + ); + $this->assertEquals('mta-sts-disabled', Hm_MTA_STS::get_status_class($result)); + } + + /** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ + public function test_clear_cache() { + // Test instance method + $mta_sts = new Hm_MTA_STS(); + $mta_sts->clear_cache(); + $this->assertTrue(true); + } + + /** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ + public function test_instance_creation() { + // Test creating instance without domain + $mta_sts = new Hm_MTA_STS(); + $this->assertInstanceOf('Hm_MTA_STS', $mta_sts); + + // Test creating instance with domain + $mta_sts = new Hm_MTA_STS('example.com'); + $this->assertInstanceOf('Hm_MTA_STS', $mta_sts); + } + + /** + * @preserveGlobalState disabled + * @runInSeparateProcess + */ + public function test_set_domain() { + $mta_sts = new Hm_MTA_STS(); + $result = $mta_sts->set_domain('example.com'); + $this->assertInstanceOf('Hm_MTA_STS', $result); + } +}