relay: send REQUIRETLS mail through relays #262
No files matched your search
@@ -284,6 +284,7 @@ pub struct Ehlo {
|
||||
pub eight_bit: bool,
|
||||
pub smtputf8: bool,
|
||||
pub dsn: bool,
|
||||
pub requiretls: bool,
|
||||
}
|
||||
|
||||
impl Ehlo {
|
||||
@@ -301,6 +302,7 @@ impl Ehlo {
|
||||
"8BITMIME" => ehlo.eight_bit = true,
|
||||
"SMTPUTF8" => ehlo.smtputf8 = true,
|
||||
"DSN" => ehlo.dsn = true,
|
||||
"REQUIRETLS" => ehlo.requiretls = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +310,11 @@ impl Ehlo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the downstream sent the message with REQUIRETLS (RFC 8689).
|
||||
pub fn requires_tls(from: &Address) -> bool {
|
||||
parameter(from, "REQUIRETLS").is_some()
|
||||
}
|
||||
|
||||
/// The value of an SMTP parameter, if the downstream gave it.
|
||||
fn parameter<'a>(address: &'a Address, name: &str) -> Option<Option<&'a str>> {
|
||||
address
|
||||
@@ -319,7 +326,7 @@ fn parameter<'a>(address: &'a Address, name: &str) -> Option<Option<&'a str>> {
|
||||
}
|
||||
|
||||
/// MAIL FROM, with only the parameters this server takes (RFC 1870, 6152,
|
||||
/// 6531, 3461). The size is the relay's own count.
|
||||
/// 6531, 3461, 8689). The size is the relay's own count.
|
||||
fn mail_from(from: &Address, ehlo: &Ehlo, size: u64, utf8: bool) -> String {
|
||||
let mut line = format!("MAIL FROM:<{}>", from.email);
|
||||
if ehlo.size.is_some() {
|
||||
@@ -331,6 +338,10 @@ fn mail_from(from: &Address, ehlo: &Ehlo, size: u64, utf8: bool) -> String {
|
||||
{
|
||||
line.push_str(" BODY=8BITMIME");
|
||||
}
|
||||
// Only ever on a session where TLS was required
|
||||
if ehlo.requiretls && requires_tls(from) {
|
||||
line.push_str(" REQUIRETLS");
|
||||
}
|
||||
if utf8 {
|
||||
line.push_str(" SMTPUTF8");
|
||||
}
|
||||
@@ -575,12 +586,27 @@ mod tests {
|
||||
size: Some(0),
|
||||
eight_bit: true,
|
||||
dsn: true,
|
||||
requiretls: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
mail_from(&from, &full, 10, true),
|
||||
"MAIL FROM:<[email protected]> SIZE=10 BODY=8BITMIME SMTPUTF8 RET=HDRS\r\n"
|
||||
);
|
||||
let mut strict = from.clone();
|
||||
strict
|
||||
.parameters
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.insert("REQUIRETLS".to_string(), None);
|
||||
assert_eq!(
|
||||
mail_from(&strict, &bare, 10, false),
|
||||
"MAIL FROM:<[email protected]>\r\n"
|
||||
);
|
||||
assert_eq!(
|
||||
mail_from(&strict, &full, 10, false),
|
||||
"MAIL FROM:<[email protected]> SIZE=10 BODY=8BITMIME REQUIRETLS RET=HDRS\r\n"
|
||||
);
|
||||
let mut rcpt = Address::new("[email protected]");
|
||||
rcpt.parameters = Some([("NOTIFY".to_string(), Some("NEVER".to_string()))].into());
|
||||
assert_eq!(rcpt_to(&rcpt, &bare), "RCPT TO:<[email protected]>\r\n");
|
||||
|
||||
@@ -189,6 +189,7 @@ impl<R: Resolve> Target<'_, R> {
|
||||
.await
|
||||
.filter(|p| p.mode != Mode::None);
|
||||
let enforce = sts.as_ref().is_some_and(|p| p.enforce());
|
||||
let requiretls = client::requires_tls(&self.meta.envelope.mail_from);
|
||||
let mut last = format!("451 4.4.0 No MX host of {domain} could be reached");
|
||||
|
||||
for host in hosts.iter().take(MX_MAX) {
|
||||
@@ -218,7 +219,9 @@ impl<R: Resolve> Target<'_, R> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let fallback = if enforce {
|
||||
// REQUIRETLS asks what the server's own delivery gives it: TLS
|
||||
// with a WebPKI certificate, unless DANE applies (RFC 8689 §4.2.1)
|
||||
let fallback = if enforce || requiretls {
|
||||
TlsMode::Verified
|
||||
} else if self.settings.outbound_tls == OutboundTls::Required {
|
||||
TlsMode::Required
|
||||
@@ -345,7 +348,14 @@ impl<R: Resolve> Target<'_, R> {
|
||||
let mut data = crate::events::data([
|
||||
("domain", json!(self.domain)),
|
||||
("mx", json!(mx)),
|
||||
("policyType", json!(mode.policy_type())),
|
||||
(
|
||||
"policyType",
|
||||
// Verified for REQUIRETLS alone isn't an MTA-STS policy
|
||||
json!(match (mode, sts) {
|
||||
(TlsMode::Verified, None) => "no-policy-found",
|
||||
_ => mode.policy_type(),
|
||||
}),
|
||||
),
|
||||
("policyStrings", json!(policy_strings)),
|
||||
(
|
||||
"result",
|
||||
|
||||
@@ -216,6 +216,10 @@ async fn relay(name: &str) -> (Arc<App>, std::path::PathBuf) {
|
||||
}
|
||||
|
||||
fn hand_over(app: &App, to: &[&str], body: &[u8]) -> String {
|
||||
hand_over_from(app, Address::new("[email protected]"), to, body)
|
||||
}
|
||||
|
||||
fn hand_over_from(app: &App, from: Address, to: &[&str], body: &[u8]) -> String {
|
||||
let blob = app.paths.blobs().join(crate::spool::new_id());
|
||||
std::fs::write(&blob, body).unwrap();
|
||||
let settings = app.settings().unwrap();
|
||||
@@ -226,7 +230,7 @@ fn hand_over(app: &App, to: &[&str], body: &[u8]) -> String {
|
||||
&blob,
|
||||
"b",
|
||||
Envelope {
|
||||
mail_from: Address::new("[email protected]"),
|
||||
mail_from: from,
|
||||
rcpt_to: to.iter().map(|t| Address::new(*t)).collect(),
|
||||
},
|
||||
&settings,
|
||||
@@ -475,6 +479,34 @@ async fn mta_sts_enforce_is_enforced() {
|
||||
assert!(seen.lock().unwrap().messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requiretls_needs_a_verified_certificate() {
|
||||
// The scripted MX's certificate is self-signed: fine for opportunistic
|
||||
// TLS, not for REQUIRETLS (RFC 8689)
|
||||
let (port, seen, _) = scripted_mx().await;
|
||||
let (app, dir) = relay("requiretls").await;
|
||||
let mut from = Address::new("[email protected]");
|
||||
from.parameters = Some([("REQUIRETLS".to_string(), None)].into());
|
||||
let id = hand_over_from(
|
||||
&app,
|
||||
from,
|
||||
&["[email protected]"],
|
||||
b"Subject: r\r\n\r\nhi\r\n",
|
||||
);
|
||||
tokio::spawn(run(app.clone(), Arc::new(hosts(port))));
|
||||
app.outbound_wake.notify_one();
|
||||
let out = tried(&app, &id).await;
|
||||
let status = &out.delivery_status["[email protected]"];
|
||||
assert_eq!(status.delivered, Delivered::Queued);
|
||||
assert!(status.smtp_reply.starts_with("451 4."), "{status:?}");
|
||||
let tls = events(&app, "outbound.tls");
|
||||
assert_eq!(tls[0]["policyType"], "no-policy-found");
|
||||
assert_ne!(tls[0]["result"], "success");
|
||||
// Not tried again in the clear either
|
||||
assert!(seen.lock().unwrap().messages.is_empty());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expiry_refuses_what_is_still_queued() {
|
||||
let (port, _, _) = scripted_mx().await;
|
||||
|
||||
@@ -239,14 +239,13 @@ impl QueuedMessage {
|
||||
}
|
||||
}
|
||||
// inbuxa: with a relay's sendThrough on, every non-local recipient
|
||||
// goes through a relay (relay spec, RL-24). REQUIRETLS mail doesn't
|
||||
// yet: the relay can't keep that promise for it.
|
||||
// goes through a relay (relay spec, RL-24)
|
||||
static THROUGH_RELAY: RoutingStrategy = RoutingStrategy::InbuxaRelay;
|
||||
let senders = &server.inner.data.relay_senders;
|
||||
if let Err(err) = senders.load(server.store()).await {
|
||||
trc::error!(err.details("Failed to read the relays").span_id(span_id));
|
||||
}
|
||||
let through_relay = senders.sending() && message.message.flags & MAIL_REQUIRETLS == 0;
|
||||
let through_relay = senders.sending();
|
||||
let mut relayed_waiting = Vec::new();
|
||||
for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() {
|
||||
if matches!(
|
||||
|
||||
@@ -23,8 +23,8 @@ use common::{Server, config::smtp::queue::QueueName, ipc::QueueEvent};
|
||||
use inbuxa_features::relay::outbound::{Handed, Refused, record_handed};
|
||||
use inbuxa_relay_proto::types::{Address, Envelope};
|
||||
use smtp_proto::{
|
||||
MAIL_BODY_8BITMIME, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE,
|
||||
RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response,
|
||||
MAIL_BODY_8BITMIME, MAIL_REQUIRETLS, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY,
|
||||
RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use store::write::now;
|
||||
@@ -153,7 +153,8 @@ impl MessageWrapper {
|
||||
}
|
||||
|
||||
/// The envelope as the relay takes it: the DSN parameters this server
|
||||
/// would send itself, and 8BITMIME (RFC 8621 `Address` parameters).
|
||||
/// would send itself, 8BITMIME, and REQUIRETLS, which the relay keeps
|
||||
/// as this server would (RFC 8621 `Address` parameters).
|
||||
fn relay_envelope(&self, rcpt_idxs: &[usize]) -> Envelope {
|
||||
let flags = self.message.flags;
|
||||
let mut from = BTreeMap::new();
|
||||
@@ -165,6 +166,9 @@ impl MessageWrapper {
|
||||
} else if flags & MAIL_RET_HDRS != 0 {
|
||||
from.insert("RET".to_string(), Some("HDRS".to_string()));
|
||||
}
|
||||
if flags & MAIL_REQUIRETLS != 0 {
|
||||
from.insert("REQUIRETLS".to_string(), None);
|
||||
}
|
||||
if let Some(env_id) = &self.message.env_id {
|
||||
from.insert("ENVID".to_string(), Some(env_id.to_string()));
|
||||
}
|
||||
|
||||
@@ -569,8 +569,12 @@ allowing the edge's IP and disabling SPF, DMARC and reverse IP for it.
|
||||
relay, for every message handed to it from any node. A final status is
|
||||
recorded in the downstream's queue, which sends any DSN as for its own
|
||||
deliveries, and the message is then destroyed on the relay.
|
||||
- Mail sent with REQUIRETLS (RFC 8689) isn't handed to a relay yet: it
|
||||
takes the route it would without relays.
|
||||
- Mail sent with REQUIRETLS (RFC 8689) is handed over with the parameter,
|
||||
and the relay treats it as the server's own delivery does: TLS is
|
||||
required, with a WebPKI certificate valid for the MX host unless DANE
|
||||
applies, never in the clear, and `REQUIRETLS` is passed on where the
|
||||
next server offers it. Its TLS failures are reported as
|
||||
`no-policy-found` unless an MTA-STS policy applied.
|
||||
|
||||
### DNS and certificates
|
||||
|
||||
@@ -827,8 +831,11 @@ In progress. Done so far:
|
||||
the relay sending to a scripted MX (RL-T11): a test build of the relay,
|
||||
with the `test-mx` feature, sends every domain to one local port.
|
||||
`outboundTls` is set like any other setting, `settings/outboundTls`.
|
||||
- Still to come in step 4: the named `inbuxaRelay` route, TLS-RPT reports
|
||||
from the relay's `outbound.tls` events, and REQUIRETLS through relays.
|
||||
- REQUIRETLS through relays: handed over with the parameter, delivered by
|
||||
the relay only over TLS with a verified certificate (tested against a
|
||||
scripted MX with a self-signed one).
|
||||
- Still to come in step 4: the named `inbuxaRelay` route, and TLS-RPT
|
||||
reports from the relay's `outbound.tls` events.
|
||||
- `crates/outbound-tls` (RL-30): the TLSA and MTA-STS types, the DANE
|
||||
certificate check, MTA-STS policy parsing and matching, and the DNSSEC
|
||||
lookups (with the bogus-answer recheck), moved out of `crates/smtp` and
|
||||
|
||||
@@ -546,6 +546,18 @@ async fn wait_for_mail(account: &Account, subject: &str, count: usize) {
|
||||
|
||||
/// Sends a message from `sender` to `to` by JMAP submission.
|
||||
async fn submit(sender: &Account, identity: &str, mailbox: &str, to: &[&str], subject: &str) {
|
||||
submit_with(sender, identity, mailbox, to, subject, None).await
|
||||
}
|
||||
|
||||
/// The same, with the submission's `envelope`, if given.
|
||||
async fn submit_with(
|
||||
sender: &Account,
|
||||
identity: &str,
|
||||
mailbox: &str,
|
||||
to: &[&str],
|
||||
subject: &str,
|
||||
envelope: Option<Value>,
|
||||
) {
|
||||
let (_, response) = call(
|
||||
sender,
|
||||
"Email/set",
|
||||
@@ -563,10 +575,14 @@ async fn submit(sender: &Account, identity: &str, mailbox: &str, to: &[&str], su
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("draft: {response}"))
|
||||
.to_string();
|
||||
let mut create = json!({"emailId": email, "identityId": identity});
|
||||
if let Some(envelope) = envelope {
|
||||
create["envelope"] = envelope;
|
||||
}
|
||||
let (_, response) = call(
|
||||
sender,
|
||||
"EmailSubmission/set",
|
||||
json!({"create": {"s": {"emailId": email, "identityId": identity}}}),
|
||||
json!({"create": {"s": create}}),
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
@@ -946,6 +962,41 @@ pub async fn link(test: &mut TestServer) {
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
// REQUIRETLS (RFC 8689): never sent in the clear, and this MX has no
|
||||
// STARTTLS, so it waits, saying why
|
||||
submit_with(
|
||||
&jane,
|
||||
&identity,
|
||||
&drafts,
|
||||
&["[email protected]"],
|
||||
"Strict one",
|
||||
Some(json!({
|
||||
"mailFrom": {"email": "[email protected]", "parameters": {"REQUIRETLS": null}},
|
||||
"rcptTo": [{"email": "[email protected]"}]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
let (_, queued) = call(admin, "x:QueuedMessage/get", json!({"ids": null})).await;
|
||||
let text = queued.to_string();
|
||||
if text.contains("[email protected]") && text.contains("doesn't offer STARTTLS") {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"REQUIRETLS held back: {queued}"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
assert!(
|
||||
!mx_seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|m| m.contains("Subject: Strict one")),
|
||||
"sent in the clear"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
assert_eq!(
|
||||
subjects(&jane)
|
||||
|
||||
Reference in new issue
Block a user