Files
Stalwart/tests/src/smtp/inbound/dmarc.rs
2023-12-18 22:25:42 +01:00

324 lines
10 KiB
Rust

/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::{
sync::Arc,
time::{Duration, Instant},
};
use directory::core::config::ConfigDirectory;
use mail_auth::{
common::{parse::TxtRecordParser, verify::DomainKey},
dkim::DomainKeyReport,
dmarc::Dmarc,
report::DmarcResult,
spf::Spf,
};
use store::Stores;
use utils::config::{Config, DynValue, Rate};
use crate::smtp::{
inbound::{sign::TextConfigContext, TestMessage, TestQueueEvent, TestReportingEvent},
session::{TestSession, VerifyResponse},
ParseTestConfig, TestConfig, TestSMTP,
};
use smtp::{
config::{
AggregateFrequency, ConfigContext, EnvelopeKey, IfBlock, MaybeDynValue, VerifyStrategy,
},
core::{Session, SMTP},
};
const DIRECTORY: &str = r#"
[directory."local"]
type = "memory"
[[directory."local".principals]]
name = "john"
description = "John Doe"
secret = "secret"
email = ["jdoe@example.com"]
"#;
#[tokio::test]
async fn dmarc() {
let mut core = SMTP::test();
let ctx = ConfigContext::new(&[]).parse_signatures();
// Create temp dir for queue
let mut qr = core.init_test_queue("smtp_dmarc_test");
// Add SPF, DKIM and DMARC records
core.resolvers.dns.txt_add(
"mx.example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all ra=spf-failures rr=e:f:s:n").unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"foobar.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"ed._domainkey.example.com",
DomainKey::parse(
concat!(
"v=DKIM1; k=ed25519; ",
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"default._domainkey.example.com",
DomainKey::parse(
concat!(
"v=DKIM1; t=s; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt",
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v",
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi",
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"_report._domainkey.example.com",
DomainKeyReport::parse(b"ra=dkim-failures; rp=100; rr=d:o:p:s:u:v:x;").unwrap(),
Instant::now() + Duration::from_secs(5),
);
core.resolvers.dns.txt_add(
"_dmarc.example.com",
Dmarc::parse(
concat!(
"v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
"rua=mailto:dmarc-feedback@example.com;",
"ruf=mailto:dmarc-failures@example.com"
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
// Create report channels
let mut rr = core.init_test_report();
let directory = Config::new(DIRECTORY)
.unwrap()
.parse_directory(&Stores::default(), None)
.unwrap();
let config = &mut core.session.config.rcpt;
config.directory = IfBlock::new(Some(MaybeDynValue::Static(
directory.directories.get("local").unwrap().clone(),
)));
let config = &mut core.session.config;
config.data.add_auth_results = IfBlock::new(true);
config.data.add_date = IfBlock::new(true);
config.data.add_message_id = IfBlock::new(true);
config.data.add_received = IfBlock::new(true);
config.data.add_return_path = IfBlock::new(true);
config.data.add_received_spf = IfBlock::new(true);
let config = &mut core.report.config;
config.dkim.send = IfBlock::new(Some(Rate {
requests: 1,
period: Duration::from_secs(1),
}));
config.dmarc.send = config.dkim.send.clone();
config.spf.send = config.dkim.send.clone();
config.dmarc_aggregate.send = IfBlock::new(AggregateFrequency::Daily);
let config = &mut core.mail_auth;
config.spf.verify_ehlo = "[{if = 'remote-ip', eq = '10.0.0.2', then = 'strict'},
{ else = 'relaxed' }]"
.parse_if(&ConfigContext::new(&[]));
config.spf.verify_mail_from = config.spf.verify_ehlo.clone();
config.dmarc.verify = IfBlock::new(VerifyStrategy::Strict);
config.arc.verify = config.dmarc.verify.clone();
config.dkim.verify = "[{if = 'sender-domain', eq = 'test.net', then = 'relaxed'},
{ else = 'strict' }]"
.parse_if(&ConfigContext::new(&[]));
let config = &mut core.report.config;
config.spf.sign = "['rsa']"
.parse_if::<Vec<DynValue<EnvelopeKey>>>(&ctx)
.map_if_block(&ctx.signers, "", "")
.unwrap();
config.dmarc.sign = "['rsa']"
.parse_if::<Vec<DynValue<EnvelopeKey>>>(&ctx)
.map_if_block(&ctx.signers, "", "")
.unwrap();
config.dkim.sign = "['rsa']"
.parse_if::<Vec<DynValue<EnvelopeKey>>>(&ctx)
.map_if_block(&ctx.signers, "", "")
.unwrap();
// SPF must pass
let core = Arc::new(core);
let mut session = Session::test(core.clone());
session.data.remote_ip = "10.0.0.2".parse().unwrap();
session.eval_session_params().await;
session.ehlo("mx.example.com").await;
session.mail_from("bill@example.com", "550 5.7.23").await;
// Expect SPF auth failure report
let message = qr.read_event().await.unwrap_message();
assert_eq!(
message.recipients.last().unwrap().address,
"spf-failures@example.com"
);
message
.read_lines()
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com;")
.assert_contains("To: spf-failures@example.com")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: spf");
// Second DKIM failure report should be rate limited
session.mail_from("bill@example.com", "550 5.7.23").await;
qr.assert_empty_queue();
// Invalid DKIM signatures should be rejected
session.data.remote_ip = "10.0.0.1".parse().unwrap();
session.eval_session_params().await;
session
.send_message(
"bill@example.com",
&["jdoe@example.com"],
"test:invalid_dkim",
"550 5.7.20",
)
.await;
// Expect DKIM auth failure report
let message = qr.read_event().await.unwrap_message();
assert_eq!(
message.recipients.last().unwrap().address,
"dkim-failures@example.com"
);
message
.read_lines()
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com;")
.assert_contains("To: dkim-failures@example.com")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: bodyhash");
// Second DKIM failure report should be rate limited
session
.send_message(
"bill@example.com",
&["jdoe@example.com"],
"test:invalid_dkim",
"550 5.7.20",
)
.await;
qr.assert_empty_queue();
// Invalid ARC should be rejected
session
.send_message(
"bill@example.com",
&["jdoe@example.com"],
"test:invalid_arc",
"550 5.7.29",
)
.await;
qr.assert_empty_queue();
// Unaligned DMARC should be rejected
core.resolvers.dns.txt_add(
"test.net",
Spf::parse(b"v=spf1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.send_message(
"joe@test.net",
&["jdoe@example.com"],
"test:invalid_dkim",
"550 5.7.1",
)
.await;
// Expect DMARC auth failure report
let message = qr.read_event().await.unwrap_message();
assert_eq!(
message.recipients.last().unwrap().address,
"dmarc-failures@example.com"
);
message
.read_lines()
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com;")
.assert_contains("To: dmarc-failures@example.com")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: dmarc")
.assert_contains("dmarc=3Dnone");
// Expect DMARC aggregate report
let report = rr.read_report().await.unwrap_dmarc();
assert_eq!(report.domain, "example.com");
assert_eq!(report.interval, AggregateFrequency::Daily);
assert_eq!(report.dmarc_record.rua().len(), 1);
assert_eq!(report.report_record.dmarc_spf_result(), DmarcResult::Fail);
// Second DMARC failure report should be rate limited
session
.send_message(
"joe@test.net",
&["jdoe@example.com"],
"test:invalid_dkim",
"550 5.7.1",
)
.await;
qr.assert_empty_queue();
// Messagess passing DMARC should be accepted
session
.send_message(
"bill@example.com",
&["jdoe@example.com"],
"test:dkim",
"250",
)
.await;
qr.read_event()
.await
.unwrap_message()
.read_lines()
.assert_contains("dkim=pass")
.assert_contains("spf=pass")
.assert_contains("dmarc=pass")
.assert_contains("Received-SPF: pass");
}