Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
419 lines
14 KiB
Rust
419 lines
14 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use crate::queue::{MessageWrapper, Metadata, spool::QueueParams};
|
|
use common::{
|
|
Server,
|
|
config::smtp::auth::{Dkim1Signer, DkimSigners},
|
|
expr::{functions::ResolveVariable, if_block::IfBlock},
|
|
};
|
|
use mail_auth::{
|
|
AuthenticatedMessage,
|
|
common::headers::HeaderWriter,
|
|
dkim2::{Hop, MessageInstance},
|
|
};
|
|
use mail_parser::{Address, parsers::MessageStream};
|
|
use std::{collections::HashSet, sync::Arc};
|
|
use utils::sanitize_email;
|
|
|
|
pub(crate) trait DkimSign: Sync + Send {
|
|
fn sign_message(
|
|
&self,
|
|
message: &mut MessageWrapper,
|
|
params: &mut QueueParams<'_, '_>,
|
|
) -> impl Future<Output = Option<Vec<u8>>> + Send;
|
|
|
|
fn eval_signers(
|
|
&self,
|
|
if_block: &IfBlock,
|
|
resolver: &impl ResolveVariable,
|
|
session_id: u64,
|
|
) -> impl Future<Output = Option<Arc<DkimSigners>>> + Send;
|
|
}
|
|
|
|
impl DkimSign for Server {
|
|
async fn sign_message(
|
|
&self,
|
|
message: &mut MessageWrapper,
|
|
params: &mut QueueParams<'_, '_>,
|
|
) -> Option<Vec<u8>> {
|
|
let signers = params.dkim_signers.as_ref().unwrap();
|
|
let raw_message = params.raw_message;
|
|
|
|
// DKIM1 signing
|
|
let mut headers = Vec::with_capacity(64);
|
|
for signer in &signers.dkim1 {
|
|
let result = match (signer, params.raw_headers) {
|
|
(Dkim1Signer::RsaSha256(signer), None) => signer.sign(raw_message),
|
|
(Dkim1Signer::Ed25519Sha256(signer), None) => signer.sign(raw_message),
|
|
(Dkim1Signer::RsaSha256(signer), Some(headers)) => {
|
|
signer.sign_chained([headers, raw_message].iter().copied())
|
|
}
|
|
(Dkim1Signer::Ed25519Sha256(signer), Some(headers)) => {
|
|
signer.sign_chained([headers, raw_message].iter().copied())
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(signature) => {
|
|
signature.write_header(&mut headers);
|
|
}
|
|
Err(err) => {
|
|
trc::error!(
|
|
trc::Error::from(err)
|
|
.span_id(params.session_id)
|
|
.details("Failed to sign message")
|
|
.caused_by(trc::location!())
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// DKIM2 signing
|
|
if let Some(signer) = &signers.dkim2
|
|
&& let Some(modified) =
|
|
AuthenticatedMessage::parse_with_opts(raw_message, params.raw_headers, true)
|
|
{
|
|
// Generate message instance
|
|
let original = params.original_authenticated_message.take().or_else(|| {
|
|
params
|
|
.original_raw_message
|
|
.and_then(AuthenticatedMessage::parse)
|
|
});
|
|
let instance = MessageInstance::from_message(&modified, original.as_ref());
|
|
if let Some(instance) = &instance {
|
|
instance.write_header(&mut headers);
|
|
}
|
|
|
|
// Obtain disclosed and undisclosed recipients
|
|
let envelopes = message.undisclosed_recipients(&modified);
|
|
|
|
// Generate DKIM2 signature for disclosed recipients
|
|
if !envelopes.disclosed_recipients.is_empty() {
|
|
match signer.sign_with_message_instance(
|
|
&modified,
|
|
instance.as_ref(),
|
|
Hop::real(
|
|
message.message.return_path.as_ref(),
|
|
envelopes.disclosed_recipients,
|
|
),
|
|
) {
|
|
Ok(signature) => {
|
|
if envelopes.undisclosed_recipients.is_empty() {
|
|
// Happy path: no undisclosed recipients, serialize signature straight to blob
|
|
signature.write_header(&mut headers);
|
|
} else {
|
|
// Undisclosed recipients present, serialize signature to metadata
|
|
let mut header = Vec::with_capacity(64);
|
|
signature.write_header(&mut header);
|
|
params.metadata.push(Metadata::Headers {
|
|
value: header.into_boxed_slice(),
|
|
id: u64::MAX,
|
|
});
|
|
}
|
|
}
|
|
Err(err) => {
|
|
trc::error!(
|
|
trc::Error::from(err)
|
|
.span_id(params.session_id)
|
|
.details("Failed to DKIM2 sign message")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate DKIM2 signature for undisclosed recipients
|
|
for (pos, rcpt) in envelopes.undisclosed_recipients {
|
|
match signer.sign_with_message_instance(
|
|
&modified,
|
|
instance.as_ref(),
|
|
Hop::real(message.message.return_path.as_ref(), [rcpt]),
|
|
) {
|
|
Ok(signature) => {
|
|
// Serialize signature to metadata
|
|
let mut header = Vec::with_capacity(64);
|
|
signature.write_header(&mut header);
|
|
params.metadata.push(Metadata::Headers {
|
|
value: header.into_boxed_slice(),
|
|
id: pos as u64,
|
|
});
|
|
}
|
|
Err(err) => {
|
|
trc::error!(
|
|
trc::Error::from(err)
|
|
.span_id(params.session_id)
|
|
.details("Failed to DKIM2 sign message")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
(!headers.is_empty()).then_some(headers)
|
|
}
|
|
|
|
async fn eval_signers(
|
|
&self,
|
|
if_block: &IfBlock,
|
|
resolver: &impl ResolveVariable,
|
|
session_id: u64,
|
|
) -> Option<Arc<DkimSigners>> {
|
|
let sign_with_domain = self
|
|
.eval_if::<String, _>(if_block, resolver, session_id)
|
|
.await?;
|
|
match self.dkim_signers(&sign_with_domain).await {
|
|
Ok(signers) => signers,
|
|
Err(err) => {
|
|
trc::error!(
|
|
err.span_id(session_id)
|
|
.details("Failed to retrieve DKIM signers")
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Dkim2Envelopes<'x> {
|
|
undisclosed_recipients: Vec<(usize, &'x str)>,
|
|
disclosed_recipients: Vec<&'x str>,
|
|
}
|
|
|
|
impl MessageWrapper {
|
|
fn undisclosed_recipients<'x>(
|
|
&'x self,
|
|
message: &AuthenticatedMessage<'_>,
|
|
) -> Dkim2Envelopes<'x> {
|
|
if self.message.recipients.len() == 1 {
|
|
return Dkim2Envelopes {
|
|
undisclosed_recipients: Vec::new(),
|
|
disclosed_recipients: vec![self.message.recipients[0].address.as_ref()],
|
|
};
|
|
}
|
|
|
|
let mut recipients = HashSet::with_capacity(self.message.recipients.len());
|
|
|
|
for addr in message.headers.iter().filter_map(|(name, value)| {
|
|
let name = name.trim_ascii();
|
|
if name.len() == 2
|
|
&& (name.eq_ignore_ascii_case(b"to") || name.eq_ignore_ascii_case(b"cc"))
|
|
{
|
|
MessageStream::new(value).parse_address().into_address()
|
|
} else {
|
|
None
|
|
}
|
|
}) {
|
|
match addr {
|
|
Address::List(addrs) => {
|
|
recipients.extend(
|
|
addrs
|
|
.iter()
|
|
.filter_map(|a| a.address())
|
|
.map(sanitize_or_lower),
|
|
);
|
|
}
|
|
Address::Group(groups) => {
|
|
for group in groups {
|
|
recipients.extend(
|
|
group
|
|
.addresses
|
|
.iter()
|
|
.filter_map(|a| a.address())
|
|
.map(sanitize_or_lower),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut undisclosed_recipients = Vec::new();
|
|
let mut disclosed_recipients = Vec::new();
|
|
for (i, rcpt) in self.message.recipients.iter().enumerate() {
|
|
if !recipients.contains(rcpt.address.as_ref())
|
|
&& !recipients.contains(&sanitize_or_lower(&rcpt.address))
|
|
{
|
|
undisclosed_recipients.push((i, rcpt.address.as_ref()));
|
|
} else {
|
|
disclosed_recipients.push(rcpt.address.as_ref());
|
|
}
|
|
}
|
|
|
|
Dkim2Envelopes {
|
|
undisclosed_recipients,
|
|
disclosed_recipients,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sanitize_or_lower(rcpt: &str) -> String {
|
|
sanitize_email(rcpt).unwrap_or_else(|| rcpt.to_lowercase())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::queue::{Message, QueueId, Recipient};
|
|
use common::config::smtp::queue::QueueName;
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
|
|
fn wrapper(recipients: &[&str]) -> MessageWrapper {
|
|
MessageWrapper {
|
|
queue_id: 0 as QueueId,
|
|
queue_name: QueueName::default(),
|
|
is_multi_queue: false,
|
|
span_id: 0,
|
|
message: Message {
|
|
created: 0,
|
|
blob_hash: Default::default(),
|
|
return_path: "[email protected]".into(),
|
|
recipients: recipients.iter().map(Recipient::new).collect(),
|
|
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
|
received_via_port: 0,
|
|
flags: 0,
|
|
env_id: None,
|
|
priority: 0,
|
|
size: 0,
|
|
metadata: Default::default(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn split(headers: &str, recipients: &[&str]) -> (Vec<String>, Vec<String>) {
|
|
let raw = format!("{headers}\r\nSubject: test\r\n\r\nbody\r\n");
|
|
let auth = AuthenticatedMessage::parse(raw.as_bytes()).expect("parse message");
|
|
let message = wrapper(recipients);
|
|
let envelopes = message.undisclosed_recipients(&auth);
|
|
let mut disclosed = envelopes
|
|
.disclosed_recipients
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect::<Vec<_>>();
|
|
let mut undisclosed = envelopes
|
|
.undisclosed_recipients
|
|
.iter()
|
|
.map(|(_, s)| s.to_string())
|
|
.collect::<Vec<_>>();
|
|
disclosed.sort();
|
|
undisclosed.sort();
|
|
(disclosed, undisclosed)
|
|
}
|
|
|
|
#[test]
|
|
fn single_recipient_is_always_disclosed() {
|
|
let (disclosed, undisclosed) = split("To: [email protected]", &["[email protected]"]);
|
|
assert_eq!(disclosed, vec!["[email protected]".to_string()]);
|
|
assert!(undisclosed.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn all_recipients_disclosed() {
|
|
let (disclosed, undisclosed) = split(
|
|
"To: [email protected], [email protected]\r\nCc: [email protected]",
|
|
&["[email protected]", "[email protected]", "[email protected]"],
|
|
);
|
|
assert_eq!(
|
|
disclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string(),
|
|
]
|
|
);
|
|
assert!(undisclosed.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_disclosed_and_undisclosed() {
|
|
let (disclosed, undisclosed) = split(
|
|
"To: [email protected]\r\nCc: [email protected]",
|
|
&[
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
],
|
|
);
|
|
assert_eq!(
|
|
disclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string()
|
|
]
|
|
);
|
|
assert_eq!(
|
|
undisclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_to_or_cc_header_all_undisclosed() {
|
|
let (disclosed, undisclosed) = split(
|
|
"From: [email protected]",
|
|
&["[email protected]", "[email protected]"],
|
|
);
|
|
assert!(disclosed.is_empty());
|
|
assert_eq!(
|
|
undisclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn group_addresses_are_disclosed() {
|
|
let (disclosed, undisclosed) = split(
|
|
"To: Team:[email protected],[email protected];",
|
|
&["[email protected]", "[email protected]", "[email protected]"],
|
|
);
|
|
assert_eq!(
|
|
disclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string()
|
|
]
|
|
);
|
|
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn header_address_casing_is_ignored() {
|
|
let (disclosed, undisclosed) = split(
|
|
"To: [email protected], [email protected]",
|
|
&["[email protected]", "[email protected]", "[email protected]"],
|
|
);
|
|
assert_eq!(
|
|
disclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string()
|
|
]
|
|
);
|
|
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn display_names_and_brackets_are_ignored() {
|
|
let (disclosed, undisclosed) = split(
|
|
"To: \"Alice Doe\" <[email protected]>\r\nCc: Bob <[email protected]>",
|
|
&["[email protected]", "[email protected]", "[email protected]"],
|
|
);
|
|
assert_eq!(
|
|
disclosed,
|
|
vec![
|
|
"[email protected]".to_string(),
|
|
"[email protected]".to_string()
|
|
]
|
|
);
|
|
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
|
}
|
|
}
|