/ src / library / mail / SpfCheck.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see
 * <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\mail;

use seekquarry\yioop\configs as C;

/**
 * Evaluates Sender Policy Framework (SPF, RFC 7208) records to decide
 * whether a connecting client address is authorized to send mail for
 * a domain. Given the connecting IP, the envelope-sender domain, and
 * the HELO name, it fetches the domain's v=spf1 TXT record and walks
 * its mechanisms, returning one of the standard SPF results.
 *
 * This implementation covers the mechanisms in everyday use: all,
 * ip4, ip6, a, mx, include, and exists, the redirect= modifier, and
 * the +, -, ~, and ? qualifiers. It enforces the RFC 7208 limit of
 * ten DNS-querying mechanisms to bound work and prevent abuse,
 * returning permerror when that limit is exceeded. Macro expansion
 * beyond the common %{d}, %{s}, %{l}, %{o}, and %{i} forms is not
 * performed; a record relying on uncommon macros may evaluate to
 * permerror, which DMARC treats as a non-aligned result rather than
 * a pass, so the failure is safe.
 *
 * Looked-up SPF records are cached through MailRecordCache so the
 * same domain is not re-queried on every message.
 *
 * @author Chris Pollett
 */
class SpfCheck
{
    /**
     * The connecting client passed the domain's policy.
     */
    const PASS = 'pass';
    /**
     * The policy explicitly disallows the client (-all reached, or a
     * matched mechanism with a fail qualifier).
     */
    const FAIL = 'fail';
    /**
     * The policy disallows the client only softly (~all).
     */
    const SOFTFAIL = 'softfail';
    /**
     * The policy is neutral about the client (?all or no match).
     */
    const NEUTRAL = 'neutral';
    /**
     * The domain publishes no SPF record.
     */
    const NONE = 'none';
    /**
     * A DNS lookup failed temporarily; the check could be retried.
     */
    const TEMPERROR = 'temperror';
    /**
     * The record could not be evaluated: malformed, too many DNS
     * lookups, or relying on an unsupported construct.
     */
    const PERMERROR = 'permerror';
    /**
     * Maximum number of DNS-querying mechanisms and modifiers a
     * single evaluation may perform, per RFC 7208 4.6.4.
     */
    const MAX_LOOKUPS = 10;
    /**
     * Evaluates SPF for a connecting client. Fetches the sender
     * domain's policy and walks it, returning one of the result
     * constants.
     *
     * @param string $ip the connecting client's IP address
     * @param string $domain the envelope-sender domain (the part
     *      after @ in MAIL FROM, or the HELO name when MAIL FROM
     *      has an empty reverse path)
     * @param string $helo the HELO/EHLO name the client gave, used
     *      to expand macros and as the sender for a null reverse
     *      path
     * @return string one of the SpfCheck result constants
     */
    public static function check($ip, $domain, $helo = '')
    {
        $ip = trim((string) $ip);
        $domain = strtolower(trim((string) $domain));
        if ($ip === '' || $domain === '') {
            return self::NONE;
        }
        $client = self::parseIp($ip);
        if ($client === null) {
            return self::PERMERROR;
        }
        $lookups = 0;
        return self::evaluateDomain($domain, $client, $domain,
            (string) $helo, $lookups);
    }
    /**
     * Fetches and evaluates the SPF record for one domain. Used for
     * the initial domain and, recursively, for include and redirect
     * targets. The sender and helo are carried through unchanged so
     * macros expand against the original message; only the domain
     * whose record is being read changes.
     *
     * @param string $domain domain whose v=spf1 record to evaluate
     * @param array $client parsed connecting address from parseIp
     * @param string $sender_domain original envelope-sender domain
     * @param string $helo original HELO name
     * @param int &$lookups running count of DNS-querying terms,
     *      shared across recursion to enforce the limit
     * @return string one of the SpfCheck result constants
     */
    private static function evaluateDomain($domain, $client,
        $sender_domain, $helo, &$lookups)
    {
        $record = self::fetchSpfRecord($domain);
        if ($record === null) {
            return self::TEMPERROR;
        }
        if ($record === '') {
            return self::NONE;
        }
        $terms = preg_split('/\s+/', trim($record));
        $redirect = '';
        foreach ($terms as $term) {
            if ($term === '' || strtolower($term) === 'v=spf1') {
                continue;
            }
            if (stripos($term, 'redirect=') === 0) {
                $redirect = substr($term, strlen('redirect='));
                continue;
            }
            if (stripos($term, 'exp=') === 0) {
                continue;
            }
            $parsed = self::parseMechanism($term);
            if ($parsed === null) {
                return self::PERMERROR;
            }
            list($qualifier, $name, $value) = $parsed;
            $matched = self::matchMechanism($name, $value, $client,
                $domain, $sender_domain, $helo, $lookups);
            if ($matched === self::PERMERROR ||
                $matched === self::TEMPERROR) {
                return $matched;
            }
            if ($matched === true) {
                return self::qualifierResult($qualifier);
            }
        }
        if ($redirect !== '') {
            if ($lookups >= self::MAX_LOOKUPS) {
                return self::PERMERROR;
            }
            $lookups++;
            return self::evaluateDomain(strtolower($redirect),
                $client, $sender_domain, $helo, $lookups);
        }
        return self::NEUTRAL;
    }
    /**
     * Splits a mechanism term into its qualifier, name, and value.
     * The qualifier is one of +, -, ~, ? and defaults to + when
     * absent. The name is the lowercased mechanism word; the value
     * is whatever followed a colon or slash, used by ip4, ip6, a,
     * mx, include, and exists.
     *
     * @param string $term one whitespace-delimited SPF term
     * @return array|null [qualifier, name, value], or null when the
     *      term is not a recognized mechanism
     */
    private static function parseMechanism($term)
    {
        $qualifier = '+';
        $first = substr($term, 0, 1);
        if ($first === '+' || $first === '-' || $first === '~' ||
            $first === '?') {
            $qualifier = $first;
            $term = substr($term, 1);
        }
        if ($term === '') {
            return null;
        }
        $value = '';
        $separator = strcspn($term, ':/=');
        if ($separator < strlen($term)) {
            $name = substr($term, 0, $separator);
            $value = substr($term, $separator);
            $lead = substr($value, 0, 1);
            if ($lead === ':' || $lead === '=') {
                /* The grammar uses ip4:1.2.3.4; some published
                   records mistakenly write ip4=1.2.3.4. Accept the
                   equals form for ip4/ip6 so a common typo does not
                   turn into a permerror, which would otherwise fail
                   an authorized sender. */
                $value = substr($value, 1);
            }
        } else {
            $name = $term;
        }
        $name = strtolower($name);
        $known = ['all', 'ip4', 'ip6', 'a', 'mx', 'include',
            'exists', 'ptr'];
        if (!in_array($name, $known)) {
            return null;
        }
        return [$qualifier, $name, $value];
    }
    /**
     * Decides whether one mechanism matches the connecting client.
     * DNS-querying mechanisms increment the shared lookup count and
     * may return PERMERROR when the limit is hit or TEMPERROR on a
     * failed query; non-matching mechanisms return false and
     * matching ones return true.
     *
     * @param string $name mechanism name from parseMechanism
     * @param string $value mechanism value from parseMechanism
     * @param array $client parsed connecting address
     * @param string $domain domain whose record is being evaluated
     * @param string $sender_domain original envelope-sender domain
     * @param string $helo original HELO name
     * @param int &$lookups running DNS-lookup count
     * @return bool|string true on match, false on no match, or a
     *      PERMERROR/TEMPERROR result constant on a lookup problem
     */
    private static function matchMechanism($name, $value, $client,
        $domain, $sender_domain, $helo, &$lookups)
    {
        if ($name === 'all') {
            return true;
        }
        if ($name === 'ip4' || $name === 'ip6') {
            return self::matchIpMechanism($name, $value, $client);
        }
        if ($name === 'ptr') {
            /* ptr is deprecated and discouraged; treat as no match
               rather than performing its reverse lookups. */
            return false;
        }
        if ($lookups >= self::MAX_LOOKUPS) {
            return self::PERMERROR;
        }
        $lookups++;
        $target = ($value === '') ? $domain :
            strtolower(self::expandMacros($value, $sender_domain,
                $client, $helo));
        if ($name === 'a') {
            return self::matchAddressRecords($target, $client);
        }
        if ($name === 'mx') {
            return self::matchMxRecords($target, $client, $lookups);
        }
        if ($name === 'include') {
            $result = self::evaluateDomain($target, $client,
                $sender_domain, $helo, $lookups);
            if ($result === self::PASS) {
                return true;
            }
            if ($result === self::TEMPERROR ||
                $result === self::PERMERROR) {
                return $result;
            }
            return false;
        }
        if ($name === 'exists') {
            $records = AsyncDnsResolver::query($target,
                AsyncDnsResolver::TYPE_A);
            return !empty($records);
        }
        return false;
    }
    /**
     * Matches an ip4 or ip6 mechanism: parses the value as an
     * address with an optional CIDR prefix and tests whether the
     * connecting client falls in that range and is of the same
     * family.
     *
     * @param string $name 'ip4' or 'ip6'
     * @param string $value address or address/prefix from the term
     * @param array $client parsed connecting address
     * @return bool whether the client is within the range
     */
    private static function matchIpMechanism($name, $value, $client)
    {
        $prefix = null;
        $slash = strpos($value, '/');
        if ($slash !== false) {
            $prefix = (int) substr($value, $slash + 1);
            $value = substr($value, 0, $slash);
        }
        $family = ($name === 'ip4') ? 4 : 6;
        $range = self::parseIp($value);
        if ($range === null || $range['family'] !== $family ||
            $client['family'] !== $family) {
            return false;
        }
        if ($prefix === null) {
            $prefix = ($family === 4) ? 32 : 128;
        }
        return self::inRange($client['packed'], $range['packed'],
            $prefix, $family);
    }
    /**
     * Matches an a mechanism by resolving the target's A and AAAA
     * records and testing the client against each.
     *
     * @param string $target host name to resolve
     * @param array $client parsed connecting address
     * @return bool whether the client equals one of the addresses
     */
    private static function matchAddressRecords($target, $client)
    {
        $type = ($client['family'] === 4) ? AsyncDnsResolver::TYPE_A :
            AsyncDnsResolver::TYPE_AAAA;
        $field = ($client['family'] === 4) ? 'ip' : 'ipv6';
        $records = AsyncDnsResolver::query($target, $type);
        if (empty($records)) {
            return false;
        }
        foreach ($records as $record) {
            $address = $record[$field] ?? '';
            $parsed = self::parseIp($address);
            if ($parsed !== null &&
                $parsed['packed'] === $client['packed']) {
                return true;
            }
        }
        return false;
    }
    /**
     * Matches an mx mechanism by resolving the target's MX hosts and
     * then each host's address records. The MX resolution and each
     * host resolution are bounded by the shared lookup count.
     *
     * @param string $target domain whose MX hosts to resolve
     * @param array $client parsed connecting address
     * @param int &$lookups running DNS-lookup count
     * @return bool|string true on match, false on none, or a
     *      PERMERROR result when the lookup limit is exceeded
     */
    private static function matchMxRecords($target, $client,
        &$lookups)
    {
        $hosts = SmtpClient::resolveMxHosts($target);
        foreach ($hosts as $host) {
            if ($lookups >= self::MAX_LOOKUPS) {
                return self::PERMERROR;
            }
            $lookups++;
            if (self::matchAddressRecords($host, $client)) {
                return true;
            }
        }
        return false;
    }
    /**
     * Fetches a domain's v=spf1 TXT record, using and populating the
     * shared mail-record cache. Returns the record text when found,
     * the empty string when the domain publishes none, or null on a
     * DNS lookup failure (so the caller can report temperror).
     *
     * @param string $domain domain to read the SPF record for
     * @return string|null the v=spf1 record, '' for none, or null
     *      on lookup failure
     */
    private static function fetchSpfRecord($domain)
    {
        $cache = MailRecordCache::getInstance();
        $cached = $cache->get(MailRecordCache::TYPE_SPF, $domain);
        if ($cached !== null) {
            return $cached;
        }
        $records = AsyncDnsResolver::query($domain,
            AsyncDnsResolver::TYPE_TXT);
        $record = '';
        $time_to_live = 0;
        foreach ($records as $entry) {
            $text = trim((string) ($entry['txt'] ?? ''));
            if (stripos($text, 'v=spf1') === 0) {
                $record = $text;
                $time_to_live = (int) ($entry['ttl'] ?? 0);
                break;
            }
        }
        $cache->put(MailRecordCache::TYPE_SPF, $domain, $record,
            $time_to_live);
        return $record;
    }
    /**
     * Expands the common SPF macros in a mechanism value: %{d} the
     * domain, %{s} the sender, %{l} the sender local part, %{o} the
     * sender domain, and %{i} the client IP. Other macro letters are
     * left untouched, which for an unsupported macro yields a target
     * that will not resolve -- a safe non-match rather than a wrong
     * pass.
     *
     * @param string $value mechanism value possibly containing
     *      macros
     * @param string $sender_domain original envelope-sender domain
     * @param array $client parsed connecting address
     * @param string $helo original HELO name
     * @return string the value with known macros expanded
     */
    private static function expandMacros($value, $sender_domain,
        $client, $helo)
    {
        if (strpos($value, '%') === false) {
            return $value;
        }
        $local = 'postmaster';
        $sender = $local . '@' . $sender_domain;
        $replacements = ['%{d}' => $sender_domain,
            '%{s}' => $sender, '%{l}' => $local,
            '%{o}' => $sender_domain, '%{i}' => $client['display'],
            '%{h}' => $helo, '%%' => '%', '%_' => ' '];
        return strtr($value, $replacements);
    }
    /**
     * Parses an IP address string into a family marker, its packed
     * binary form for range tests, and a normalized display form.
     * Returns null when the string is not a valid IPv4 or IPv6
     * address.
     *
     * @param string $ip address text
     * @return array|null ['family'=>4|6, 'packed'=>binary,
     *      'display'=>string], or null when invalid
     */
    private static function parseIp($ip)
    {
        $ip = trim($ip);
        if ($ip === '') {
            return null;
        }
        $packed = @inet_pton($ip);
        if ($packed === false) {
            return null;
        }
        $family = (strlen($packed) === 4) ? 4 : 6;
        return ['family' => $family, 'packed' => $packed,
            'display' => $ip];
    }
    /**
     * Tests whether two packed addresses share their leading prefix
     * bits, the comparison behind a CIDR range match.
     *
     * @param string $client packed client address
     * @param string $range packed range base address
     * @param int $prefix number of leading bits that must match
     * @param int $family 4 or 6, to bound the prefix
     * @return bool whether the client lies in the range
     */
    private static function inRange($client, $range, $prefix,
        $family)
    {
        $width = ($family === 4) ? 32 : 128;
        if ($prefix < 0 || $prefix > $width) {
            return false;
        }
        $whole = intdiv($prefix, 8);
        if ($whole > 0 && strncmp($client, $range, $whole) !== 0) {
            return false;
        }
        $bits = $prefix % 8;
        if ($bits === 0) {
            return true;
        }
        $mask = 0xff << (8 - $bits) & 0xff;
        $client_byte = ord($client[$whole]);
        $range_byte = ord($range[$whole]);
        return ($client_byte & $mask) === ($range_byte & $mask);
    }
    /**
     * Maps a matched mechanism's qualifier to its SPF result.
     *
     * @param string $qualifier one of +, -, ~, ?
     * @return string the corresponding result constant
     */
    private static function qualifierResult($qualifier)
    {
        if ($qualifier === '-') {
            return self::FAIL;
        }
        if ($qualifier === '~') {
            return self::SOFTFAIL;
        }
        if ($qualifier === '?') {
            return self::NEUTRAL;
        }
        return self::PASS;
    }
}
X