summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormdecimus <mauro@stalw.art>2024-08-29 12:22:44 +0200
committermdecimus <mauro@stalw.art>2024-08-29 12:22:44 +0200
commit36fd5797b7626d0efab44f486f23ce81f26daa5a (patch)
treeeaaf24cbbbed50cae5d605c6de1e70a8c0e5a024
parent7e1b6bd06d6c5a0294f9468c32c9b67b9e9bcca4 (diff)
SYN flood, brute force fail2ban + session.mail.is-allowed expression (closes #482 closes #688 closes #609)v0.9.3
-rw-r--r--CHANGELOG.md19
-rw-r--r--Cargo.lock26
-rw-r--r--crates/cli/Cargo.toml2
-rw-r--r--crates/common/Cargo.toml2
-rw-r--r--crates/common/src/config/smtp/session.rs11
-rw-r--r--crates/common/src/lib.rs6
-rw-r--r--crates/common/src/listener/blocked.rs112
-rw-r--r--crates/common/src/listener/listen.rs2
-rw-r--r--crates/common/src/telemetry/metrics/store.rs6
-rw-r--r--crates/common/src/telemetry/tracers/store.rs2
-rw-r--r--crates/directory/Cargo.toml2
-rw-r--r--crates/imap/Cargo.toml2
-rw-r--r--crates/jmap/Cargo.toml2
-rw-r--r--crates/jmap/src/api/http.rs5
-rw-r--r--crates/main/Cargo.toml2
-rw-r--r--crates/managesieve/Cargo.toml2
-rw-r--r--crates/nlp/Cargo.toml2
-rw-r--r--crates/pop3/Cargo.toml2
-rw-r--r--crates/smtp/Cargo.toml2
-rw-r--r--crates/smtp/src/inbound/auth.rs2
-rw-r--r--crates/smtp/src/inbound/mail.rs23
-rw-r--r--crates/smtp/src/inbound/rcpt.rs34
-rw-r--r--crates/smtp/src/inbound/spawn.rs32
-rw-r--r--crates/store/Cargo.toml2
-rw-r--r--crates/trc/Cargo.toml2
-rw-r--r--crates/trc/src/event/description.rs36
-rw-r--r--crates/trc/src/event/level.rs11
-rw-r--r--crates/trc/src/event/mod.rs16
-rw-r--r--crates/trc/src/ipc/metrics.rs4
-rw-r--r--crates/trc/src/lib.rs12
-rw-r--r--crates/trc/src/serializers/binary.rs14
-rw-r--r--crates/utils/Cargo.toml2
-rw-r--r--tests/src/jmap/enterprise.rs6
-rw-r--r--tests/src/jmap/mod.rs4
-rw-r--r--tests/src/smtp/inbound/mail.rs16
35 files changed, 318 insertions, 107 deletions
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. <hello@stalw.art>"]
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)]
@@ -367,6 +368,11 @@ impl SessionConfig {
&has_sender_vars,
),
(
+ &mut session.mail.is_allowed,
+ "session.mail.is-allowed",
+ &has_sender_vars,
+ ),
+ (
&mut session.rcpt.script,
"session.rcpt.script",
&has_rcpt_vars,
@@ -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<IpAddrMask>,
has_networks: bool,
- limiter_rate: Option<Rate>,
+ auth_fail_rate: Option<Rate>,
+ rcpt_fail_rate: Option<Rate>,
+ loiter_fail_rate: Option<Rate>,
}
#[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::<Rate>("authentication.fail2ban", "100/1d"),
+ auth_fail_rate: config
+ .property_or_default::<Option<Rate>>("server.fail2ban.authentication", "100/1d")
+ .unwrap_or_default(),
+ rcpt_fail_rate: config
+ .property_or_default::<Option<Rate>>("server.fail2ban.invalid-rcpt", "35/1d")
+ .unwrap_or_default(),
+ loiter_fail_rate: config
+ .property_or_default::<Option<Rate>>("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<bool> {
- if let Some(rate) = &self.network.blocked_ips.limiter_rate {
+ pub async fn is_rcpt_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
+ if let Some(rate) = &self.network.blocked_ips.rcpt_fail_rate {
+ let is_allowed = self.is_ip_allowed(&ip)
+ || self
+ .storage
+ .lookup
+ .is_rate_allowed(format!("r:{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_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
+ 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<bool> {
+ 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)
+ .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)
+ .is_rate_allowed(format!("b:{login}").as_bytes(), rate, false)
.await?
.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()
+ 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<ServerInstance> {
// 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<T: SessionStream> Session<T> {
)
.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<T: SessionStream> Session<T> {
}
.into();
+ // Check whether the address is allowed
+ if !self
+ .core
+ .core
+ .eval_if::<bool, _>(
+ &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<T: SessionStream> Session<T> {
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<T: SessionStream> Session<T> {
.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<EventType> {
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<Value>) -> 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]
@@ -196,6 +197,14 @@ pub enum HttpEvent {
}
#[event_type]
+pub enum SecurityEvent {
+ AuthenticationBan,
+ BruteForceBan,
+ LoiterBan,
+ IpBlocked,
+}
+
+#[event_type]
pub enum ClusterEvent {
PeerAlive,
PeerDiscovered,
@@ -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<Core>) {
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,10 +118,17 @@ 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:<bill@blocked.com>\r\n")
+ .await
+ .unwrap();
+ session.response().assert_code("550 5.7.1");
+
+ // Both IPREV and SPF should pass
+ session
.ingest(b"MAIL FROM:<bill@foobar.org>\r\n")
.await
.unwrap();