Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
georglauterbach opened this issue Dec 9, 2023 · 9 comments · Fixed by #3726 or #3913
Closed

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

georglauterbach opened this issue Dec 9, 2023 · 9 comments · Fixed by #3726 or #3913
Assignees
Labels
area/configuration (file) kind/update Update an existing feature, configuration file or the documentation service/security/rspamd
Milestone

Comments

@georglauterbach
Copy link
Member

georglauterbach commented Dec 9, 2023

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.

@georglauterbach georglauterbach added area/configuration (file) kind/update Update an existing feature, configuration file or the documentation service/security/rspamd labels Dec 9, 2023
@georglauterbach georglauterbach added this to the v13.1.0 milestone Dec 9, 2023
@georglauterbach georglauterbach self-assigned this Dec 9, 2023
@georglauterbach georglauterbach pinned this issue Dec 11, 2023
@ap-wtioit

This comment was marked as resolved.

@georglauterbach

This comment was marked as outdated.

@georglauterbach

This comment was marked as outdated.

@georglauterbach

This comment was marked as outdated.

@georglauterbach georglauterbach modified the milestones: v13.1.0, v14.0.0 Dec 29, 2023
@Nebucatnetzer

This comment was marked as resolved.

@georglauterbach

This comment was marked as resolved.

@2GetApp

This comment was marked as resolved.

@georglauterbach

This comment was marked as resolved.

@georglauterbach
Copy link
Member Author

I updated and extended the original issue description quite heavily now. Please take a look at it again.

I will re-open this issue and provide another PR that fixes the issue @2GetApp made me aware of and extends the current symbol weights.

georglauterbach added a commit that referenced this issue Feb 29, 2024
I updated the symbol weights according to my new insights in #3690 to
fix a bug pointed out by @2GetApp and to improve the logic itself.
Previously, I reasoned about combinations of symbols that cannot exists,
e.g., SPF allow, DKIM allow, DMARC reject. Removing these symbols and
then reasoning about the rest is more appropriate.

Moreover, I added `DMARC_POLICY_NA` and `DMARC_POLICY_SOFTFAIL` to the
whole calculation.

The issue description of #3690 I updated. I also added the Rust code I
used to do and verify the calculations.
@georglauterbach georglauterbach pinned this issue Mar 4, 2024
georglauterbach added a commit that referenced this issue Mar 4, 2024
See updates to #3690:

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.
georglauterbach added a commit that referenced this issue Mar 5, 2024
…3923)

* move `policies_group.conf` to correct location

I originally assumed the file had to be placed into `scores.d`, but I
now know that `local.d` is actually correct.

* add configuration for composite symbols

See updates to #3690:

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.

* update CHANGELOG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment