diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fb6dc2..8cffc747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [0.9.3] - 2024-08-29 + +To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. + +## Added +- Dashboard (Enterprise feature) +- Alerts (Enterprise feature) +- SYN Flood (session "loitering") attack protection (#482) +- Mailbox brute force protection (#688) +- Mail from is allowed (`session.mail.is-allowed`) expression (#609) + +### Changed +- `authentication.fail2ban` setting renamed to `server.fail2ban.authentication`. +- Added elapsed times to message filtering events. + +### Fixed +- Include queueId in MTA Hooks (#708) +- Do not insert empty keywords in FTS index. + ## [0.9.2] - 2024-08-21 To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. diff --git a/Cargo.lock b/Cargo.lock index a0127d60..3846476f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "common" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -1650,7 +1650,7 @@ dependencies = [ [[package]] name = "directory" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "argon2", @@ -2979,7 +2979,7 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]] name = "imap" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "common", @@ -3191,7 +3191,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.9.2" +version = "0.9.3" dependencies = [ "aes", "aes-gcm", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.9.2" +version = "0.9.3" dependencies = [ "common", "directory", @@ -3648,7 +3648,7 @@ dependencies = [ [[package]] name = "managesieve" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "bincode", @@ -3947,7 +3947,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "bincode", @@ -4498,7 +4498,7 @@ dependencies = [ [[package]] name = "pop3" -version = "0.9.2" +version = "0.9.3" dependencies = [ "common", "imap", @@ -6050,7 +6050,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "smtp" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "bincode", @@ -6166,7 +6166,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stalwart-cli" -version = "0.9.2" +version = "0.9.3" dependencies = [ "clap", "console", @@ -6197,7 +6197,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "arc-swap", @@ -6824,7 +6824,7 @@ dependencies = [ [[package]] name = "trc" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "base64 0.22.1", @@ -7067,7 +7067,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utils" -version = "0.9.2" +version = "0.9.3" dependencies = [ "ahash 0.8.11", "base64 0.22.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 952f2b9e..67435502 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only OR LicenseRef-SEL" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.9.2" +version = "0.9.3" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 03b78bea..fc1718ac 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "common" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index 9a6a56fe..6a997ed3 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -97,6 +97,7 @@ pub struct Auth { pub struct Mail { pub script: IfBlock, pub rewrite: IfBlock, + pub is_allowed: IfBlock, } #[derive(Clone)] @@ -366,6 +367,11 @@ impl SessionConfig { "session.mail.rewrite", &has_sender_vars, ), + ( + &mut session.mail.is_allowed, + "session.mail.is-allowed", + &has_sender_vars, + ), ( &mut session.rcpt.script, "session.rcpt.script", @@ -761,6 +767,11 @@ impl Default for SessionConfig { mail: Mail { script: IfBlock::empty("session.mail.script"), rewrite: IfBlock::empty("session.mail.rewrite"), + is_allowed: IfBlock::new::<()>( + "session.mail.is-allowed", + [], + "!is_empty(authenticated_as) || !key_exists('spam-block', sender_domain)", + ), }, rcpt: Rcpt { script: IfBlock::empty("session.rcpt.script"), diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9ba90e49..733b9670 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -291,10 +291,10 @@ impl Core { if let Err(err) = result { Err(err) - } else if self.has_fail2ban() { + } else if self.has_auth_fail2ban() { let login = credentials.login(); - if self.is_fail2banned(remote_ip, login.to_string()).await? { - Err(trc::AuthEvent::Banned + if self.is_auth_fail2banned(remote_ip, login).await? { + Err(trc::SecurityEvent::AuthenticationBan .into_err() .ctx(trc::Key::RemoteIp, remote_ip) .ctx(trc::Key::AccountName, login.to_string())) diff --git a/crates/common/src/listener/blocked.rs b/crates/common/src/listener/blocked.rs index d7fef628..a46a3ce1 100644 --- a/crates/common/src/listener/blocked.rs +++ b/crates/common/src/listener/blocked.rs @@ -21,7 +21,9 @@ pub struct BlockedIps { pub version: AtomicU8, ip_networks: Vec, has_networks: bool, - limiter_rate: Option, + auth_fail_rate: Option, + rcpt_fail_rate: Option, + loiter_fail_rate: Option, } #[derive(Clone)] @@ -63,7 +65,15 @@ impl BlockedIps { ip_addresses: RwLock::new(ip_addresses), has_networks: !ip_networks.is_empty(), ip_networks, - limiter_rate: config.property_or_default::("authentication.fail2ban", "100/1d"), + auth_fail_rate: config + .property_or_default::>("server.fail2ban.authentication", "100/1d") + .unwrap_or_default(), + rcpt_fail_rate: config + .property_or_default::>("server.fail2ban.invalid-rcpt", "35/1d") + .unwrap_or_default(), + loiter_fail_rate: config + .property_or_default::>("server.fail2ban.loitering", "150/1d") + .unwrap_or_default(), version: 0.into(), } } @@ -108,46 +118,86 @@ impl AllowedIps { } impl Core { - pub async fn is_fail2banned(&self, ip: IpAddr, login: String) -> trc::Result { - if let Some(rate) = &self.network.blocked_ips.limiter_rate { + pub async fn is_rcpt_fail2banned(&self, ip: IpAddr) -> trc::Result { + if let Some(rate) = &self.network.blocked_ips.rcpt_fail_rate { let is_allowed = self.is_ip_allowed(&ip) - || (self + || self .storage .lookup - .is_rate_allowed(format!("b:{}", ip).as_bytes(), rate, false) + .is_rate_allowed(format!("r:{ip}").as_bytes(), rate, false) .await? - .is_none() - && self - .storage - .lookup - .is_rate_allowed(format!("b:{}", login).as_bytes(), rate, false) - .await? - .is_none()); + .is_none(); + if !is_allowed { - // Add IP to blocked list - self.network.blocked_ips.ip_addresses.write().insert(ip); - - // Write blocked IP to config - self.storage - .config - .set([ConfigKey { - key: format!("{}.{}", BLOCKED_IP_KEY, ip), - value: String::new(), - }]) - .await?; - - // Increment version - self.network.blocked_ips.increment_version(); - - return Ok(true); + return self.block_ip(ip).await.map(|_| true); } } Ok(false) } - pub fn has_fail2ban(&self) -> bool { - self.network.blocked_ips.limiter_rate.is_some() + pub async fn is_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result { + if let Some(rate) = &self.network.blocked_ips.loiter_fail_rate { + let is_allowed = self.is_ip_allowed(&ip) + || self + .storage + .lookup + .is_rate_allowed(format!("l:{ip}").as_bytes(), rate, false) + .await? + .is_none(); + + if !is_allowed { + return self.block_ip(ip).await.map(|_| true); + } + } + + Ok(false) + } + + pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: &str) -> trc::Result { + if let Some(rate) = &self.network.blocked_ips.auth_fail_rate { + let is_allowed = self.is_ip_allowed(&ip) + || (self + .storage + .lookup + .is_rate_allowed(format!("b:{ip}").as_bytes(), rate, false) + .await? + .is_none() + && self + .storage + .lookup + .is_rate_allowed(format!("b:{login}").as_bytes(), rate, false) + .await? + .is_none()); + if !is_allowed { + return self.block_ip(ip).await.map(|_| true); + } + } + + Ok(false) + } + + async fn block_ip(&self, ip: IpAddr) -> trc::Result<()> { + // Add IP to blocked list + self.network.blocked_ips.ip_addresses.write().insert(ip); + + // Write blocked IP to config + self.storage + .config + .set([ConfigKey { + key: format!("{}.{}", BLOCKED_IP_KEY, ip), + value: String::new(), + }]) + .await?; + + // Increment version + self.network.blocked_ips.increment_version(); + + Ok(()) + } + + pub fn has_auth_fail2ban(&self) -> bool { + self.network.blocked_ips.auth_fail_rate.is_some() } pub fn is_ip_blocked(&self, ip: &IpAddr) -> bool { @@ -186,8 +236,10 @@ impl Default for BlockedIps { ip_addresses: RwLock::new(AHashSet::new()), ip_networks: Default::default(), has_networks: Default::default(), - limiter_rate: Default::default(), version: Default::default(), + auth_fail_rate: Default::default(), + rcpt_fail_rate: Default::default(), + loiter_fail_rate: Default::default(), } } } @@ -216,11 +268,13 @@ impl Clone for BlockedIps { ip_addresses: RwLock::new(self.ip_addresses.read().clone()), ip_networks: self.ip_networks.clone(), has_networks: self.has_networks, - limiter_rate: self.limiter_rate.clone(), version: self .version .load(std::sync::atomic::Ordering::Relaxed) .into(), + auth_fail_rate: self.auth_fail_rate.clone(), + rcpt_fail_rate: self.rcpt_fail_rate.clone(), + loiter_fail_rate: self.loiter_fail_rate.clone(), } } } @@ -230,7 +284,11 @@ impl Debug for BlockedIps { f.debug_struct("BlockedIps") .field("ip_addresses", &self.ip_addresses) .field("ip_networks", &self.ip_networks) - .field("limiter_rate", &self.limiter_rate) + .field("has_networks", &self.has_networks) + .field("version", &self.version) + .field("auth_fail_rate", &self.auth_fail_rate) + .field("rcpt_fail_rate", &self.rcpt_fail_rate) + .field("loiter_fail_rate", &self.loiter_fail_rate) .finish() } } diff --git a/crates/common/src/listener/listen.rs b/crates/common/src/listener/listen.rs index 5461b918..a2877ce9 100644 --- a/crates/common/src/listener/listen.rs +++ b/crates/common/src/listener/listen.rs @@ -230,7 +230,7 @@ impl BuildSession for Arc { // Check if blocked if core.is_ip_blocked(&remote_ip) { trc::event!( - Network(trc::NetworkEvent::DropBlocked), + Security(trc::SecurityEvent::IpBlocked), ListenerId = self.id.clone(), LocalPort = local_addr.port(), RemoteIp = remote_ip, diff --git a/crates/common/src/telemetry/metrics/store.rs b/crates/common/src/telemetry/metrics/store.rs index e9a564a0..1510b4de 100644 --- a/crates/common/src/telemetry/metrics/store.rs +++ b/crates/common/src/telemetry/metrics/store.rs @@ -105,8 +105,10 @@ impl MetricsStore for Store { EventType::MessageIngest(MessageIngestEvent::Ham), EventType::MessageIngest(MessageIngestEvent::Spam), EventType::Auth(AuthEvent::Failed), - EventType::Auth(AuthEvent::Banned), - EventType::Network(NetworkEvent::DropBlocked), + EventType::Security(SecurityEvent::AuthenticationBan), + EventType::Security(SecurityEvent::BruteForceBan), + EventType::Security(SecurityEvent::LoiterBan), + EventType::Security(SecurityEvent::IpBlocked), EventType::IncomingReport(IncomingReportEvent::DmarcReport), EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), EventType::IncomingReport(IncomingReportEvent::TlsReport), diff --git a/crates/common/src/telemetry/tracers/store.rs b/crates/common/src/telemetry/tracers/store.rs index a6297528..3de86b08 100644 --- a/crates/common/src/telemetry/tracers/store.rs +++ b/crates/common/src/telemetry/tracers/store.rs @@ -418,12 +418,12 @@ impl StoreTracer { AuthEvent::Success | AuthEvent::Failed | AuthEvent::TooManyAttempts - | AuthEvent::Banned | AuthEvent::Error ) | EventType::Sieve(_) | EventType::Milter(_) | EventType::MtaHook(_) + | EventType::Security(_) ) }) } diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 5124bbbc..2d93eead 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "directory" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 77258fcb..b28d3698 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index 41a8a425..f0e7a21f 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index f00fccd7..c758fadc 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -885,11 +885,10 @@ impl ToRequestError for trc::Error { trc::AuthEvent::MissingTotp => { RequestError::blank(403, "TOTP code required", cause.message()) } - trc::AuthEvent::TooManyAttempts | trc::AuthEvent::Banned => { - RequestError::too_many_auth_attempts() - } + trc::AuthEvent::TooManyAttempts => RequestError::too_many_auth_attempts(), _ => RequestError::unauthorized(), }, + trc::EventType::Security(_) => RequestError::too_many_auth_attempts(), trc::EventType::Resource(cause) => match cause { trc::ResourceEvent::NotFound => RequestError::not_found(), trc::ResourceEvent::BadParameters => RequestError::blank( diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 856326c7..e09d9acc 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/managesieve/Cargo.toml b/crates/managesieve/Cargo.toml index 8fa35009..e2dbdffb 100644 --- a/crates/managesieve/Cargo.toml +++ b/crates/managesieve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "managesieve" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index 73d0bda0..cc7059bd 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/pop3/Cargo.toml b/crates/pop3/Cargo.toml index 1d58ad43..2a1fe3be 100644 --- a/crates/pop3/Cargo.toml +++ b/crates/pop3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pop3" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index b732077c..165454d1 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only OR LicenseRef-SEL" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 38cf1398..4e8131cd 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -206,7 +206,7 @@ impl Session { ) .await; } - trc::EventType::Auth(trc::AuthEvent::Banned) => { + trc::EventType::Security(_) => { return Err(()); } _ => (), diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index ad4dbb8a..ec1b3474 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -120,6 +120,29 @@ impl Session { } .into(); + // Check whether the address is allowed + if !self + .core + .core + .eval_if::( + &self.core.core.smtp.session.mail.is_allowed, + self, + self.data.session_id, + ) + .await + .unwrap_or(true) + { + let mail_from = self.data.mail_from.take().unwrap(); + trc::event!( + Smtp(SmtpEvent::MailFromNotAllowed), + From = mail_from.address_lcase, + SpanId = self.data.session_id, + ); + return self + .write(b"550 5.7.1 Sender address not allowed.\r\n") + .await; + } + // Sieve filtering if let Some((script, script_id)) = self .core diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index e7bdafed..f95098af 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -8,7 +8,7 @@ use common::{config::smtp::session::Stage, listener::SessionStream, scripts::Scr use smtp_proto::{ RcptTo, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, }; -use trc::SmtpEvent; +use trc::{SecurityEvent, SmtpEvent}; use crate::{ core::{Session, SessionAddress}, @@ -315,11 +315,33 @@ impl Session { if self.data.rcpt_errors < self.params.rcpt_errors_max { Ok(()) } else { - trc::event!( - Smtp(SmtpEvent::TooManyInvalidRcpt), - SpanId = self.data.session_id, - Limit = self.params.rcpt_errors_max, - ); + match self + .core + .core + .is_rcpt_fail2banned(self.data.remote_ip) + .await + { + Ok(true) => { + trc::event!( + Security(SecurityEvent::BruteForceBan), + SpanId = self.data.session_id, + RemoteIp = self.data.remote_ip, + ); + } + Ok(false) => { + trc::event!( + Smtp(SmtpEvent::TooManyInvalidRcpt), + SpanId = self.data.session_id, + Limit = self.params.rcpt_errors_max, + ); + } + Err(err) => { + trc::error!(err + .span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to check if IP should be banned.")); + } + } self.write(b"421 4.3.0 Too many errors, disconnecting.\r\n") .await?; diff --git a/crates/smtp/src/inbound/spawn.rs b/crates/smtp/src/inbound/spawn.rs index 8690c7d6..36800abb 100644 --- a/crates/smtp/src/inbound/spawn.rs +++ b/crates/smtp/src/inbound/spawn.rs @@ -11,7 +11,7 @@ use common::{ listener::{self, SessionManager, SessionStream}, }; use tokio_rustls::server::TlsStream; -use trc::SmtpEvent; +use trc::{SecurityEvent, SmtpEvent}; use crate::{ core::{Session, SessionData, SessionParameters, SmtpSessionManager, State}, @@ -194,10 +194,32 @@ impl Session { .await .ok(); - trc::event!( - Smtp(SmtpEvent::TimeLimitExceeded), - SpanId = self.data.session_id, - ); + match self + .core + .core + .is_loiter_fail2banned(self.data.remote_ip) + .await + { + Ok(true) => { + trc::event!( + Security(SecurityEvent::LoiterBan), + SpanId = self.data.session_id, + RemoteIp = self.data.remote_ip, + ); + } + Ok(false) => { + trc::event!( + Smtp(SmtpEvent::TimeLimitExceeded), + SpanId = self.data.session_id, + ); + } + Err(err) => { + trc::error!(err + .span_id(self.data.session_id) + .caused_by(trc::location!()) + .details("Failed to check if IP should be banned.")); + } + } break; } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index a990a7a5..cfe22bb6 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "store" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/trc/Cargo.toml b/crates/trc/Cargo.toml index 172b40fe..47fec7f9 100644 --- a/crates/trc/Cargo.toml +++ b/crates/trc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trc" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 597674a9..c0ecb311 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -50,6 +50,7 @@ impl EventType { EventType::OutgoingReport(event) => event.description(), EventType::Telemetry(event) => event.description(), EventType::MessageIngest(event) => event.description(), + EventType::Security(event) => event.description(), } } @@ -96,6 +97,7 @@ impl EventType { EventType::OutgoingReport(event) => event.explain(), EventType::Telemetry(event) => event.explain(), EventType::MessageIngest(event) => event.explain(), + EventType::Security(event) => event.explain(), } } } @@ -431,6 +433,7 @@ impl SmtpEvent { SmtpEvent::MailFromUnauthorized => "MAIL FROM unauthorized", SmtpEvent::MailFromRewritten => "MAIL FROM address rewritten", SmtpEvent::MailFromMissing => "MAIL FROM address missing", + SmtpEvent::MailFromNotAllowed => "MAIL FROM not allowed", SmtpEvent::MailFrom => "SMTP MAIL FROM command", SmtpEvent::MultipleMailFrom => "Multiple MAIL FROM commands", SmtpEvent::MailboxDoesNotExist => "Mailbox does not exist", @@ -536,6 +539,9 @@ impl SmtpEvent { SmtpEvent::MailFromMissing => { "The remote client issued an RCPT TO command before MAIL FROM" } + SmtpEvent::MailFromNotAllowed => { + "The remote client is not allowed to send mail from this address" + } SmtpEvent::MailFrom => "The remote client sent a MAIL FROM command", SmtpEvent::MultipleMailFrom => "The remote client already sent a MAIL FROM command", SmtpEvent::MailboxDoesNotExist => "The mailbox does not exist on the server", @@ -1114,7 +1120,6 @@ impl NetworkEvent { NetworkEvent::Closed => "Network connection closed", NetworkEvent::ProxyError => "Proxy protocol error", NetworkEvent::SetOptError => "Network set option error", - NetworkEvent::DropBlocked => "Dropped connection from blocked IP address", } } @@ -1133,7 +1138,6 @@ impl NetworkEvent { NetworkEvent::Closed => "The network connection was closed", NetworkEvent::ProxyError => "An error occurred with the proxy protocol", NetworkEvent::SetOptError => "An error occurred while setting network options", - NetworkEvent::DropBlocked => "The connection was dropped from a blocked IP address", } } } @@ -1736,7 +1740,6 @@ impl AuthEvent { AuthEvent::Failed => "Authentication failed", AuthEvent::MissingTotp => "Missing TOTP for authentication", AuthEvent::TooManyAttempts => "Too many authentication attempts", - AuthEvent::Banned => "IP address banned after multiple authentication failures", AuthEvent::Error => "Authentication error", } } @@ -1747,9 +1750,6 @@ impl AuthEvent { AuthEvent::Failed => "Failed authentication", AuthEvent::MissingTotp => "TOTP is missing for authentication", AuthEvent::TooManyAttempts => "Too many authentication attempts have been made", - AuthEvent::Banned => { - "The IP address has been banned after multiple authentication failures" - } AuthEvent::Error => "An error occurred with authentication", } } @@ -1776,3 +1776,27 @@ impl ResourceEvent { } } } + +impl SecurityEvent { + pub fn description(&self) -> &'static str { + match self { + SecurityEvent::AuthenticationBan => "Banned due to authentication errors", + SecurityEvent::BruteForceBan => "Banned due to brute force attack", + SecurityEvent::LoiterBan => "Banned due to loitering", + SecurityEvent::IpBlocked => "Blocked IP address", + } + } + + pub fn explain(&self) -> &'static str { + match self { + SecurityEvent::AuthenticationBan => { + "IP address was banned due to multiple authentication errors" + } + SecurityEvent::BruteForceBan => { + "IP address was banned due to possible brute force attack" + } + SecurityEvent::LoiterBan => "IP address was banned due to multiple loitering events", + SecurityEvent::IpBlocked => "Rejected connection from blocked IP address", + } + } +} diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 4142f045..e4de67e1 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -126,6 +126,7 @@ impl EventType { | SmtpEvent::MailFromRewritten | SmtpEvent::MailFromMissing | SmtpEvent::MultipleMailFrom + | SmtpEvent::MailFromNotAllowed | SmtpEvent::RcptToDuplicate | SmtpEvent::RcptToRewritten | SmtpEvent::RcptToMissing @@ -203,9 +204,7 @@ impl EventType { | NetworkEvent::FlushError | NetworkEvent::Closed => Level::Trace, NetworkEvent::Timeout | NetworkEvent::AcceptError => Level::Debug, - NetworkEvent::ListenStart - | NetworkEvent::ListenStop - | NetworkEvent::DropBlocked => Level::Info, + NetworkEvent::ListenStart | NetworkEvent::ListenStop => Level::Info, NetworkEvent::ListenError | NetworkEvent::BindError | NetworkEvent::SetOptError @@ -228,7 +227,6 @@ impl EventType { AuthEvent::Failed => Level::Debug, AuthEvent::MissingTotp => Level::Trace, AuthEvent::TooManyAttempts => Level::Warn, - AuthEvent::Banned => Level::Warn, AuthEvent::Error => Level::Error, AuthEvent::Success => Level::Info, }, @@ -275,9 +273,9 @@ impl EventType { | PurgeEvent::TombstoneCleanup => Level::Debug, }, EventType::Eval(event) => match event { - EvalEvent::Error => Level::Debug, + EvalEvent::Error | EvalEvent::StoreNotFound => Level::Debug, EvalEvent::Result => Level::Trace, - EvalEvent::DirectoryNotFound | EvalEvent::StoreNotFound => Level::Warn, + EvalEvent::DirectoryNotFound => Level::Warn, }, EventType::Server(event) => match event { ServerEvent::Startup | ServerEvent::Shutdown | ServerEvent::Licensing => { @@ -536,6 +534,7 @@ impl EventType { | MessageIngestEvent::Duplicate => Level::Info, MessageIngestEvent::Error => Level::Error, }, + EventType::Security(_) => Level::Info, } } } diff --git a/crates/trc/src/event/mod.rs b/crates/trc/src/event/mod.rs index c320ed01..761a49e3 100644 --- a/crates/trc/src/event/mod.rs +++ b/crates/trc/src/event/mod.rs @@ -155,17 +155,15 @@ impl Event { matches!( self.inner, EventType::Network(_) - | EventType::Auth(AuthEvent::TooManyAttempts | AuthEvent::Banned) + | EventType::Auth(AuthEvent::TooManyAttempts) | EventType::Limit(LimitEvent::ConcurrentRequest | LimitEvent::TooManyRequests) + | EventType::Security(_) ) } #[inline(always)] pub fn should_write_err(&self) -> bool { - !matches!( - self.inner, - EventType::Network(_) | EventType::Auth(AuthEvent::Banned) - ) + !matches!(self.inner, EventType::Network(_) | EventType::Security(_)) } pub fn corrupted_key(key: &[u8], value: Option<&[u8]>, caused_by: &'static str) -> Error { @@ -317,6 +315,13 @@ impl StoreEvent { } } +impl SecurityEvent { + #[inline(always)] + pub fn into_err(self) -> Error { + Error::new(EventType::Security(self)) + } +} + impl AuthEvent { #[inline(always)] pub fn ctx(self, key: Key, value: impl Into) -> Error { @@ -346,7 +351,6 @@ impl AuthEvent { "Try authenticating again using 'secret$totp_token'." ), Self::TooManyAttempts => "Too many authentication attempts", - Self::Banned => "Banned", _ => "Authentication error", } } diff --git a/crates/trc/src/ipc/metrics.rs b/crates/trc/src/ipc/metrics.rs index b6297ba9..ee08f7fc 100644 --- a/crates/trc/src/ipc/metrics.rs +++ b/crates/trc/src/ipc/metrics.rs @@ -525,14 +525,14 @@ impl EventType { | HttpEvent::ResponseBody | HttpEvent::XForwardedMissing, ) => true, - EventType::Network(NetworkEvent::Timeout | NetworkEvent::DropBlocked) => true, + EventType::Network(NetworkEvent::Timeout) => true, + EventType::Security(_) => true, EventType::Limit(_) => true, EventType::Manage(_) => false, EventType::Auth( AuthEvent::Success | AuthEvent::Failed | AuthEvent::TooManyAttempts - | AuthEvent::Banned | AuthEvent::Error, ) => true, EventType::Config(_) => false, diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index d8551d33..c3e914ae 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -182,6 +182,7 @@ pub enum EventType { IncomingReport(IncomingReportEvent), OutgoingReport(OutgoingReportEvent), Telemetry(TelemetryEvent), + Security(SecurityEvent), } #[event_type] @@ -195,6 +196,14 @@ pub enum HttpEvent { XForwardedMissing, } +#[event_type] +pub enum SecurityEvent { + AuthenticationBan, + BruteForceBan, + LoiterBan, + IpBlocked, +} + #[event_type] pub enum ClusterEvent { PeerAlive, @@ -371,6 +380,7 @@ pub enum SmtpEvent { LhloExpected, MailFromUnauthenticated, MailFromUnauthorized, + MailFromNotAllowed, MailFromRewritten, MailFromMissing, MailFrom, @@ -639,7 +649,6 @@ pub enum NetworkEvent { Closed, ProxyError, SetOptError, - DropBlocked, } #[event_type] @@ -915,7 +924,6 @@ pub enum AuthEvent { Failed, MissingTotp, TooManyAttempts, - Banned, Error, } diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 02a681e9..68f346b1 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -339,7 +339,7 @@ impl EventType { EventType::Arc(ArcEvent::InvalidCv) => 30, EventType::Arc(ArcEvent::InvalidInstance) => 31, EventType::Arc(ArcEvent::SealerNotFound) => 32, - EventType::Auth(AuthEvent::Banned) => 33, + EventType::Security(SecurityEvent::AuthenticationBan) => 33, EventType::Auth(AuthEvent::Error) => 34, EventType::Auth(AuthEvent::Failed) => 35, EventType::Auth(AuthEvent::MissingTotp) => 36, @@ -624,7 +624,7 @@ impl EventType { EventType::Network(NetworkEvent::AcceptError) => 315, EventType::Network(NetworkEvent::BindError) => 316, EventType::Network(NetworkEvent::Closed) => 317, - EventType::Network(NetworkEvent::DropBlocked) => 318, + EventType::Security(SecurityEvent::IpBlocked) => 318, EventType::Network(NetworkEvent::FlushError) => 319, EventType::Network(NetworkEvent::ListenError) => 320, EventType::Network(NetworkEvent::ListenStart) => 321, @@ -855,6 +855,9 @@ impl EventType { EventType::Tls(TlsEvent::NoCertificatesAvailable) => 546, EventType::Tls(TlsEvent::NotConfigured) => 547, EventType::Telemetry(TelemetryEvent::Alert) => 548, + EventType::Security(SecurityEvent::BruteForceBan) => 549, + EventType::Security(SecurityEvent::LoiterBan) => 550, + EventType::Smtp(SmtpEvent::MailFromNotAllowed) => 551, } } @@ -893,7 +896,7 @@ impl EventType { 30 => Some(EventType::Arc(ArcEvent::InvalidCv)), 31 => Some(EventType::Arc(ArcEvent::InvalidInstance)), 32 => Some(EventType::Arc(ArcEvent::SealerNotFound)), - 33 => Some(EventType::Auth(AuthEvent::Banned)), + 33 => Some(EventType::Security(SecurityEvent::AuthenticationBan)), 34 => Some(EventType::Auth(AuthEvent::Error)), 35 => Some(EventType::Auth(AuthEvent::Failed)), 36 => Some(EventType::Auth(AuthEvent::MissingTotp)), @@ -1196,7 +1199,7 @@ impl EventType { 315 => Some(EventType::Network(NetworkEvent::AcceptError)), 316 => Some(EventType::Network(NetworkEvent::BindError)), 317 => Some(EventType::Network(NetworkEvent::Closed)), - 318 => Some(EventType::Network(NetworkEvent::DropBlocked)), + 318 => Some(EventType::Security(SecurityEvent::IpBlocked)), 319 => Some(EventType::Network(NetworkEvent::FlushError)), 320 => Some(EventType::Network(NetworkEvent::ListenError)), 321 => Some(EventType::Network(NetworkEvent::ListenStart)), @@ -1449,6 +1452,9 @@ impl EventType { 546 => Some(EventType::Tls(TlsEvent::NoCertificatesAvailable)), 547 => Some(EventType::Tls(TlsEvent::NotConfigured)), 548 => Some(EventType::Telemetry(TelemetryEvent::Alert)), + 549 => Some(EventType::Security(SecurityEvent::BruteForceBan)), + 550 => Some(EventType::Security(SecurityEvent::LoiterBan)), + 551 => Some(EventType::Smtp(SmtpEvent::MailFromNotAllowed)), _ => None, } } diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index f3941b97..42c642f6 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.9.2" +version = "0.9.3" edition = "2021" resolver = "2" diff --git a/tests/src/jmap/enterprise.rs b/tests/src/jmap/enterprise.rs index 74744c2b..2135a84e 100644 --- a/tests/src/jmap/enterprise.rs +++ b/tests/src/jmap/enterprise.rs @@ -406,9 +406,11 @@ pub async fn insert_test_metrics(core: Arc) { EventType::Queue(QueueEvent::QueueReport), EventType::MessageIngest(MessageIngestEvent::Ham), EventType::MessageIngest(MessageIngestEvent::Spam), - EventType::Auth(AuthEvent::Banned), EventType::Auth(AuthEvent::Failed), - EventType::Network(NetworkEvent::DropBlocked), + EventType::Security(SecurityEvent::AuthenticationBan), + EventType::Security(SecurityEvent::BruteForceBan), + EventType::Security(SecurityEvent::LoiterBan), + EventType::Security(SecurityEvent::IpBlocked), EventType::IncomingReport(IncomingReportEvent::DmarcReport), EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), EventType::IncomingReport(IncomingReportEvent::TlsReport), diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index a68a5885..ddc7c618 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -100,8 +100,10 @@ enable = true implicit = false certificate = "default" +[server.fail2ban] +authentication = "101/5s" + [authentication] -fail2ban = "101/5s" rate-limit = "100/2s" [session.ehlo] diff --git a/tests/src/smtp/inbound/mail.rs b/tests/src/smtp/inbound/mail.rs index 3f905c7d..9cdf96d4 100644 --- a/tests/src/smtp/inbound/mail.rs +++ b/tests/src/smtp/inbound/mail.rs @@ -56,6 +56,9 @@ requiretls = [{if = "remote_ip = '10.0.0.2'", then = true}, mt-priority = [{if = "remote_ip = '10.0.0.2'", then = 'nsep'}, {else = false}] +[session.mail] +is-allowed = "sender_domain != 'blocked.com'" + [session.data.limits] size = [{if = "remote_ip = '10.0.0.2'", then = 2048}, {else = 1024}] @@ -70,8 +73,8 @@ enable = true #[tokio::test] async fn mail() { - // Enable logging - crate::enable_logging(); + // Enable logging + crate::enable_logging(); let tmp_dir = TempDir::new("smtp_mail_test", true); let mut config = Config::new(tmp_dir.update_config(CONFIG)).unwrap(); @@ -115,9 +118,16 @@ async fn mail() { .unwrap(); session.response().assert_code("503 5.5.1"); - // Both IPREV and SPF should pass + // Test sender not allowed session.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap(); session.response().assert_code("250"); + session + .ingest(b"MAIL FROM:\r\n") + .await + .unwrap(); + session.response().assert_code("550 5.7.1"); + + // Both IPREV and SPF should pass session .ingest(b"MAIL FROM:\r\n") .await