Skip to content

Rspamd: provide RFC7489 compliance for SPF, DKIM & DMARC #3690

@georglauterbach

Description

@georglauterbach

About

Rspamd should comply with RFC7489 to the best of its abilities.

DMS' Action Scores

Milter action scores are configured to:

greylist = 4;
add_header = 6;
reject = 11;

Symbols

DMARC policies can be

  1. Allow
  2. Allow but with failure (either SPF or DKIM failed)
  3. N/A (non-existent)
  4. Quarantine
  5. Reject

DKIM policies can be

  1. Allow
  2. N/A (non-existent)
  3. Temp-Fail
  4. Perm-Fail

SPF policies can be

  1. Allow
  2. N/A (non-existent)
  3. Soft-fail
  4. Fail

Behavior

Here is a table of possible combinations of what can happen with SPF, DKIM & DMARC.

SPF DKIM DMARC Action
R_SPF_ALLOW ( -1) R_DKIM_ALLOW ( -1) DMARC_POLICY_ALLOW ( -1) pass ( -3)
R_SPF_ALLOW ( -1) R_DKIM_ALLOW ( -1) DMARC_POLICY_NA ( 0.5) pass (-1.5)
R_SPF_ALLOW ( -1) R_DKIM_ALLOW ( -1) DMARC_POLICY_SOFTFAIL ( 1.5) pass (-0.5)
R_SPF_ALLOW ( -1) R_DKIM_NA ( 1) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 0)
R_SPF_ALLOW ( -1) R_DKIM_NA ( 1) DMARC_POLICY_NA ( 0.5) pass ( 0.5)
R_SPF_ALLOW ( -1) R_DKIM_NA ( 1) DMARC_POLICY_SOFTFAIL ( 1.5) pass ( 1.5)
R_SPF_ALLOW ( -1) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 0.5)
R_SPF_ALLOW ( -1) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_NA ( 0.5) pass ( 1)
R_SPF_ALLOW ( -1) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_SOFTFAIL ( 1.5) pass ( 2)
R_SPF_ALLOW ( -1) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 3.5)
R_SPF_ALLOW ( -1) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_NA ( 0.5) greylist ( 4)
R_SPF_ALLOW ( -1) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 5)
R_SPF_NA ( 1.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 0.5)
R_SPF_NA ( 1.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_NA ( 0.5) pass ( 1)
R_SPF_NA ( 1.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_SOFTFAIL ( 1.5) pass ( 2)
R_SPF_NA ( 1.5) R_DKIM_NA ( 1) DMARC_POLICY_QUARANTINE ( 3) greylist ( 5.5)
R_SPF_NA ( 1.5) R_DKIM_NA ( 1) DMARC_POLICY_REJECT ( 5.5) add_header ( 8)
R_SPF_NA ( 1.5) R_DKIM_NA ( 1) DMARC_POLICY_NA ( 0.5) pass ( 3)
R_SPF_NA ( 1.5) R_DKIM_NA ( 1) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 4)
R_SPF_NA ( 1.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_QUARANTINE ( 3) add_header ( 6)
R_SPF_NA ( 1.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_REJECT ( 5.5) add_header ( 8.5)
R_SPF_NA ( 1.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_NA ( 0.5) pass ( 3.5)
R_SPF_NA ( 1.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 4.5)
R_SPF_NA ( 1.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_QUARANTINE ( 3) add_header ( 9)
R_SPF_NA ( 1.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_REJECT ( 5.5) reject (11.5)
R_SPF_NA ( 1.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_NA ( 0.5) add_header ( 6.5)
R_SPF_NA ( 1.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_SOFTFAIL ( 1.5) add_header ( 7.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 1.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_NA ( 0.5) pass ( 2)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_SOFTFAIL ( 1.5) pass ( 3)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_NA ( 1) DMARC_POLICY_QUARANTINE ( 3) add_header ( 6.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_NA ( 1) DMARC_POLICY_REJECT ( 5.5) add_header ( 9)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_NA ( 1) DMARC_POLICY_NA ( 0.5) greylist ( 4)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_NA ( 1) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_QUARANTINE ( 3) add_header ( 7)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_REJECT ( 5.5) add_header ( 9.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_NA ( 0.5) greylist ( 4.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 5.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_QUARANTINE ( 3) add_header ( 10)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_REJECT ( 5.5) reject (12.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_NA ( 0.5) add_header ( 7.5)
R_SPF_SOFTFAIL ( 2.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_SOFTFAIL ( 1.5) add_header ( 8.5)
R_SPF_FAIL ( 4.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_ALLOW_WITH_FAILURES ( 0) pass ( 3.5)
R_SPF_FAIL ( 4.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_NA ( 0.5) greylist ( 4)
R_SPF_FAIL ( 4.5) R_DKIM_ALLOW ( -1) DMARC_POLICY_SOFTFAIL ( 1.5) greylist ( 5)
R_SPF_FAIL ( 4.5) R_DKIM_NA ( 1) DMARC_POLICY_QUARANTINE ( 3) add_header ( 8.5)
R_SPF_FAIL ( 4.5) R_DKIM_NA ( 1) DMARC_POLICY_REJECT ( 5.5) reject ( 11)
R_SPF_FAIL ( 4.5) R_DKIM_NA ( 1) DMARC_POLICY_NA ( 0.5) add_header ( 6)
R_SPF_FAIL ( 4.5) R_DKIM_NA ( 1) DMARC_POLICY_SOFTFAIL ( 1.5) add_header ( 7)
R_SPF_FAIL ( 4.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_QUARANTINE ( 3) add_header ( 9)
R_SPF_FAIL ( 4.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_REJECT ( 5.5) reject (11.5)
R_SPF_FAIL ( 4.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_NA ( 0.5) add_header ( 6.5)
R_SPF_FAIL ( 4.5) R_DKIM_TEMPFAIL ( 1.5) DMARC_POLICY_SOFTFAIL ( 1.5) add_header ( 7.5)
R_SPF_FAIL ( 4.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_QUARANTINE ( 3) reject ( 12)
R_SPF_FAIL ( 4.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_REJECT ( 5.5) reject (14.5)
R_SPF_FAIL ( 4.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_NA ( 0.5) add_header ( 9.5)
R_SPF_FAIL ( 4.5) R_DKIM_PERMFAIL ( 4.5) DMARC_POLICY_SOFTFAIL ( 1.5) add_header (10.5)

Rspamd Configuration File

Please see scores.d/policies_group.conf

The Code that Generates All of This

Click me to unveil the Rust code behind the scenes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum MilterActions {
    Reject = 11,
    AddHeader = 6,
    Greylist = 4,
    Pass = 0,
}

impl SPFResult {
    fn weight(&self) -> f32 {
        match self {
            Self::RSpfAllow => -1.0,
            Self::RSpfNa => 1.5,
            Self::RSpfSoftfail => 2.5,
            Self::RSpfFail => 4.5,
        }
    }
}

impl DKIMResult {
    fn weight(&self) -> f32 {
        match self {
            Self::RDkimAllow => -1.0,
            Self::RDkimNa => 1.0,
            Self::RDkimTempfail => 1.5,
            Self::RDkimPermfail => 4.5,
        }
    }
}

impl DMARCResult {
    fn weight(&self) -> f32 {
        match self {
            Self::DmarcPolicyAllow => -1.0,
            Self::DmarcPolicyAllowWithFailures => 0.0,
            Self::DmarcPolicyNa => 0.5,
            Self::DmarcPolicySoftfail => 1.5,
            Self::DmarcPolicyQuarantine => 3.0,
            Self::DmarcPolicyReject => 5.5,
        }
    }
}

impl MilterActions {
    fn into_float(self) -> f32 {
        self as usize as f32
    }

    fn from_combination(combination: Combination) -> (Self, f32) {
        let score = combination.0.weight() + combination.1.weight() + combination.2.weight();
        let action = if score >= Self::Reject.into_float() {
            Self::Reject
        } else if score >= Self::AddHeader.into_float() {
            Self::AddHeader
        } else if score >= Self::Greylist.into_float() {
            Self::Greylist
        } else {
            Self::Pass
        };

        (action, score)
    }
}

impl std::fmt::Display for MilterActions {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        let self_string = match self {
            Self::Reject => "`reject`",
            Self::AddHeader => "`add_header`",
            Self::Greylist => "`greylist`",
            Self::Pass => "`pass`",
        };
        self_string.fmt(f)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum SPFResult {
    RSpfAllow,
    RSpfNa,
    RSpfSoftfail,
    RSpfFail,
}

impl std::fmt::Display for SPFResult {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::RSpfAllow => "`R_SPF_ALLOW`",
            Self::RSpfNa => "`R_SPF_NA`",
            Self::RSpfSoftfail => "`R_SPF_SOFTFAIL`",
            Self::RSpfFail => "`R_SPF_FAIL`",
        }.fmt(f)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum DKIMResult {
    RDkimAllow,
    RDkimNa,
    RDkimTempfail,
    RDkimPermfail,
}

impl std::fmt::Display for DKIMResult {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::RDkimAllow => "`R_DKIM_ALLOW`",
            Self::RDkimNa => "`R_DKIM_NA`",
            Self::RDkimTempfail => "`R_DKIM_TEMPFAIL`",
            Self::RDkimPermfail => "`R_DKIM_PERMFAIL`",
        }.fmt(f)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum DMARCResult {
    DmarcPolicyAllow,
    DmarcPolicyNa,
    DmarcPolicySoftfail,
    DmarcPolicyAllowWithFailures,
    DmarcPolicyQuarantine,
    DmarcPolicyReject,
}

impl std::fmt::Display for DMARCResult {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::DmarcPolicyAllow => "`DMARC_POLICY_ALLOW`",
            Self::DmarcPolicyNa => "`DMARC_POLICY_NA`",
            Self::DmarcPolicySoftfail => "`DMARC_POLICY_SOFTFAIL`",
            Self::DmarcPolicyAllowWithFailures => "`DMARC_POLICY_ALLOW_WITH_FAILURES`",
            Self::DmarcPolicyQuarantine => "`DMARC_POLICY_QUARANTINE`",
            Self::DmarcPolicyReject => "`DMARC_POLICY_REJECT`",
        }
        .fmt(f)
    }
}

type Combination = (SPFResult, DKIMResult, DMARCResult);

fn produce_combinations() -> Vec<Combination> {
    let mut combinations = vec![];

    use DKIMResult::*;
    use DMARCResult::*;
    use SPFResult::*;

    for spf in [RSpfAllow, RSpfNa, RSpfSoftfail, RSpfFail] {
        for dkim in [RDkimAllow, RDkimNa, RDkimTempfail, RDkimPermfail] {
            // SPF and DKIM succeeded
            if spf == RSpfAllow && dkim == RDkimAllow {
                combinations.push((spf, dkim, DmarcPolicyAllow));

            // SPF failed
            } else if spf == RSpfNa || spf == RSpfSoftfail || spf == RSpfFail {
                if dkim == RDkimAllow {
                    combinations.push((spf, dkim, DmarcPolicyAllowWithFailures));
                } else {
                    combinations.push((spf, dkim, DmarcPolicyQuarantine));
                    combinations.push((spf, dkim, DmarcPolicyReject));
                }

            // DKIM failed
            } else if dkim == RDkimNa || dkim == RDkimTempfail || dkim == RDkimPermfail {
                if spf == RSpfAllow {
                    combinations.push((spf, dkim, DmarcPolicyAllowWithFailures));
                } else {
                    combinations.push((spf, dkim, DmarcPolicyQuarantine));
                    combinations.push((spf, dkim, DmarcPolicyReject));
                }
            } else {
                panic!("Leftover combination: {} {}", spf, dkim);
            }

            // these can always happen
            combinations.push((spf, dkim, DmarcPolicyNa));
            combinations.push((spf, dkim, DmarcPolicySoftfail));
        }
    }

    combinations
}

fn print_row(combination: Combination) {
    let (action, score) = MilterActions::from_combination(combination);
    println!(
        "| {:16} ({:>4}) | {:17} ({:>4}) | {:34} ({:>4}) | {:12} ({:>4}) |",
        combination.0,
        combination.0.weight(),
        combination.1,
        combination.1.weight(),
        combination.2,
        combination.2.weight(),
        action,
        score
    );
}

fn main() {
    println!(
        "| {:16}        | {:17}        | {:34}        | {:12}        |",
        "SPF", "DKIM", "DMARC", "Action"
    );
    println!("| :---------------------- | :----------------------- | :---------------------------------------- | :------------------ |");

    for combination in produce_combinations() {
        print_row(combination);
    }
}

#[test]
fn combinations_produce_correct_milter_action() {
    use DKIMResult::*;
    use DMARCResult::*;
    use SPFResult::*;

    let combinations = produce_combinations();

    macro_rules! is_equal {
        ($combination:expr, $milter_action:expr) => {
            assert!(combinations.contains(&$combination));
            assert!(MilterActions::from_combination($combination).0 == $milter_action);
        };
    }

    is_equal!(
        (RSpfAllow, RDkimAllow, DmarcPolicyAllow),
        MilterActions::Pass
    );
    is_equal!(
        (RSpfAllow, RDkimNa, DmarcPolicyAllowWithFailures),
        MilterActions::Pass
    );
    is_equal!(
        (RSpfAllow, RDkimTempfail, DmarcPolicyAllowWithFailures),
        MilterActions::Pass
    );
    is_equal!(
        (RSpfAllow, RDkimPermfail, DmarcPolicyAllowWithFailures),
        MilterActions::Pass
    );
    is_equal!(
        (RSpfAllow, RDkimPermfail, DmarcPolicyAllowWithFailures),
        MilterActions::Pass
    );
    is_equal!(
        (RSpfAllow, RDkimPermfail, DmarcPolicyAllowWithFailures),
        MilterActions::Pass
    );

    is_equal!(
        (RSpfNa, RDkimPermfail, DmarcPolicyReject),
        MilterActions::Reject
    );
    is_equal!(
        (RSpfFail, RDkimNa, DmarcPolicyReject),
        MilterActions::Reject
    );
    is_equal!(
        (RSpfFail, RDkimTempfail, DmarcPolicyReject),
        MilterActions::Reject
    );
    is_equal!(
        (RSpfFail, RDkimPermfail, DmarcPolicyReject),
        MilterActions::Reject
    );
}

#[test]
fn certain_combinations_must_not_exist() {
    use DKIMResult::*;
    use DMARCResult::*;
    use SPFResult::*;

    let combinations = produce_combinations();

    macro_rules! must_not_exist {
        ($combination:expr) => {
            assert!(!combinations.contains(&$combination));
        };
    }

    must_not_exist!((RSpfAllow, RDkimAllow, DmarcPolicyReject));
    must_not_exist!((RSpfNa, RDkimAllow, DmarcPolicyReject));
    must_not_exist!((RSpfSoftfail, RDkimAllow, DmarcPolicyReject));
    must_not_exist!((RSpfFail, RDkimAllow, DmarcPolicyReject));
    must_not_exist!((RSpfAllow, RDkimNa, DmarcPolicyReject));
    must_not_exist!((RSpfAllow, RDkimTempfail, DmarcPolicyReject));
    must_not_exist!((RSpfAllow, RDkimTempfail, DmarcPolicyReject));

    must_not_exist!((RSpfAllow, RDkimAllow, DmarcPolicyQuarantine));
    must_not_exist!((RSpfNa, RDkimAllow, DmarcPolicyQuarantine));
    must_not_exist!((RSpfSoftfail, RDkimAllow, DmarcPolicyQuarantine));
    must_not_exist!((RSpfFail, RDkimAllow, DmarcPolicyQuarantine));
    must_not_exist!((RSpfAllow, RDkimNa, DmarcPolicyQuarantine));
    must_not_exist!((RSpfAllow, RDkimTempfail, DmarcPolicyQuarantine));
    must_not_exist!((RSpfAllow, RDkimTempfail, DmarcPolicyQuarantine));
}

You can copy this code and run it to print the table seen above. When you use Cargo, you may also use cargo test to check whether changes still conform to specifications.

Additional Rspamd Symbols

Rspamd has so-called composite symbols that trigger when a condition is met. Especially AUTH_NA and AUTH_NA_OR_FAIL will adjust the scores of various lines in the table above. This needs to be taken into account.

You Think There is Something Wrong Here?

In case you think a value should be changed, please copy the Rust code, apply your changes to the top, and then test the result. You may add additional tests to combinations_produce_correct_milter_action as well. Please justify why you disagree with the current setup.

Metadata

Metadata

Type

No type

Projects

Status

Done

Status

Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions