spf-milter 0.6.0

Milter for SPF verification
Documentation
// SPF Milter – milter for SPF verification
// Copyright © 2020–2023 David Bürgin <dbuergin@gluet.ch>
//
// 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/>.

use crate::{
    config::{model::ExplainStringMod, Config},
    resolver::Resolver,
};
use std::{
    borrow::Cow,
    fmt::{self, Display, Formatter},
    net::IpAddr,
};
use viaspf::{record::ExplainString, DomainName, Sender, SpfResult, SpfResultCause};

/// An SPF HELO or MAIL FROM identity.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Identity {
    // Note: this carries the sender identity in the original format:
    // lower/upper case, Unicode, trailing dot, etc.
    Helo(String),
    MailFrom(String),
}

impl Identity {
    pub fn name(&self) -> &'static str {
        match self {
            Self::Helo(_) => "helo",
            Self::MailFrom(_) => "mailfrom",
        }
    }
}

impl AsRef<str> for Identity {
    fn as_ref(&self) -> &str {
        match self {
            Self::Helo(s) | Self::MailFrom(s) => s,
        }
    }
}

impl Display for Identity {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        self.as_ref().fmt(f)
    }
}

/// A verification result produced by verifying an identity.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct VerificationResult {
    pub identity: Identity,
    pub spf_result: SpfResult,
    pub cause: Option<SpfResultCause>,
}

impl VerificationResult {
    pub fn new(identity: Identity, spf_result: SpfResult, cause: Option<SpfResultCause>) -> Self {
        Self {
            identity,
            spf_result,
            cause,
        }
    }
}

pub fn prepare_mail_from_identity<'a>(
    mut mail_from: &'a str,
    helo_host: Option<&str>,
) -> Cow<'a, str> {
    if let Some(s) = mail_from
        .strip_prefix('<')
        .and_then(|s| s.strip_suffix('>'))
    {
        mail_from = s;
    }

    // Section 2.4 of RFC 7208 mandates that the HELO identity with local-part
    // `postmaster` be used when the MAIL FROM identity is empty. (The resulting
    // mailbox may be ill-formed.)
    if mail_from.is_empty() {
        let helo_host = helo_host.unwrap_or("unknown");

        format!("postmaster@{helo_host}").into()
    } else {
        mail_from.into()
    }
}

enum Params {
    Helo(Sender),
    MailFrom(Sender, Option<DomainName>),
}

pub struct Verifier<'a> {
    resolver: &'a Resolver,
    config: viaspf::Config,
    ip: IpAddr,
    params: Option<Params>,
}

impl<'a> Verifier<'a> {
    pub fn new(resolver: &'a Resolver, config: &Config, hostname: &str, ip: IpAddr) -> Self {
        let config = to_verifier_config(config, hostname);

        Self {
            resolver,
            config,
            ip,
            params: None,
        }
    }

    // Design note: The asymmetry in the `verify_*` methods is deliberate. At
    // the end of the authorisation process we want to have a final result.
    // Therefore, the MAIL FROM identity always produces a result, even with
    // unusable inputs. The HELO identity, on the other hand, may legitimately
    // be an address literal or may be missing entirely; verification is not
    // done in such cases.

    pub async fn verify_helo(&mut self, helo_host: &str) -> Option<VerificationResult> {
        // The HELO hostname is not necessarily in FQDN format. The spec says
        // that a HELO check can only be performed when HELO is an FQDN, so we
        // make verification dependent on this condition.

        let sender = Sender::from_domain(helo_host).ok()?;
        let helo_domain = Some(sender.domain());

        let config = &self.config;
        let result = match self.resolver {
            Resolver::Live(resolver) => {
                viaspf::evaluate_sender(resolver, config, self.ip, &sender, helo_domain).await
            }
            Resolver::Mock(mock) => {
                viaspf::evaluate_sender(mock.as_ref(), config, self.ip, &sender, helo_domain).await
            }
        };

        self.params = Some(Params::Helo(sender));

        let id = Identity::Helo(helo_host.into());

        Some(VerificationResult::new(id, result.spf_result, result.cause))
    }

    pub async fn verify_mail_from(
        &mut self,
        mail_from: &str,
        helo_host: Option<&str>,
    ) -> VerificationResult {
        let id = Identity::MailFrom(mail_from.into());

        let sender = match Sender::from_address(mail_from) {
            Ok(sender) => sender,
            Err(_) => return VerificationResult::new(id, SpfResult::None, None),
        };

        let helo_domain = helo_host.and_then(|s| s.parse().ok());

        let config = &self.config;
        let result = match self.resolver {
            Resolver::Live(resolver) => {
                viaspf::evaluate_sender(resolver, config, self.ip, &sender, helo_domain.as_ref())
                    .await
            }
            Resolver::Mock(mock) => {
                viaspf::evaluate_sender(mock.as_ref(), config, self.ip, &sender, helo_domain.as_ref())
                    .await
            }
        };

        self.params = Some(Params::MailFrom(sender, helo_domain));

        VerificationResult::new(id, result.spf_result, result.cause)
    }

    pub async fn expand_explain_string(&self, exp: &ExplainString) -> String {
        let params = self.params.as_ref().expect("verification not done");

        let (sender, helo_domain) = match params {
            Params::Helo(sender) => (sender, Some(sender.domain())),
            Params::MailFrom(sender, helo_domain) => (sender, helo_domain.as_ref()),
        };

        let config = &self.config;
        let result = match self.resolver {
            Resolver::Live(resolver) => {
                viaspf::expand_explain_string(resolver, config, exp, self.ip, sender, helo_domain)
                    .await
            }
            Resolver::Mock(mock) => {
                viaspf::expand_explain_string(mock.as_ref(), config, exp, self.ip, sender, helo_domain)
                    .await
            }
        };

        result.expansion
    }
}

fn to_verifier_config(config: &Config, hostname: &str) -> viaspf::Config {
    let mut builder = viaspf::Config::builder()
        .hostname(hostname)
        .timeout(config.timeout())
        .max_lookups(config.max_lookups())
        .max_void_lookups(config.max_void_lookups());

    match config.fail_reply_text_exp().as_ref() {
        ExplainStringMod::Substitute(explain_string) => {
            let explain_string = explain_string.to_owned();
            builder = builder.modify_exp_with(move |exp| {
                exp.segments = explain_string.segments.clone();
            });
        }
        ExplainStringMod::Decorate { prefix, suffix } => {
            let (prefix, suffix) = (prefix.to_owned(), suffix.to_owned());
            builder = builder.modify_exp_with(move |exp| {
                exp.segments.splice(..0, prefix.segments.iter().cloned());
                exp.segments.extend(suffix.segments.iter().cloned());
            });
        }
    }

    builder.build()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::model::Socket;

    #[test]
    fn to_verifier_config_exp_explain_string() {
        let config = Config::builder(Socket::Inet("unused".into()))
            .fail_reply_text_exp(ExplainStringMod::Decorate {
                prefix: "<".parse().unwrap(),
                suffix: ">".parse().unwrap(),
            })
            .build()
            .unwrap();

        let config = to_verifier_config(&config, "unused");

        let f = config.modify_exp_fn().unwrap();

        let mut explain_string = "denied".parse().unwrap();
        f(&mut explain_string);

        // Have to compare string representations, because the decorated and the
        // parsed explain-string have different internal structure!
        assert_eq!(
            explain_string.to_string(),
            "<denied>".parse::<ExplainString>().unwrap().to_string()
        );
    }
}