SYN flood, brute force fail2ban + session.mail.is-allowed expression (closes #482 closes #688 closes #609)

This commit is contained in:
mdecimus 2024-08-29 12:22:44 +02:00
parent 7e1b6bd06d
commit 36fd5797b7
35 changed files with 325 additions and 114 deletions

View file

@ -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/). 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 ## [0.9.2] - 2024-08-21
To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin. To upgrade replace the `stalwart-mail` binary and then upgrade to the latest web-admin.

26
Cargo.lock generated
View file

@ -1042,7 +1042,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"arc-swap", "arc-swap",
@ -1650,7 +1650,7 @@ dependencies = [
[[package]] [[package]]
name = "directory" name = "directory"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"argon2", "argon2",
@ -2979,7 +2979,7 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]] [[package]]
name = "imap" name = "imap"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"common", "common",
@ -3191,7 +3191,7 @@ dependencies = [
[[package]] [[package]]
name = "jmap" name = "jmap"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@ -3629,7 +3629,7 @@ dependencies = [
[[package]] [[package]]
name = "mail-server" name = "mail-server"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"common", "common",
"directory", "directory",
@ -3648,7 +3648,7 @@ dependencies = [
[[package]] [[package]]
name = "managesieve" name = "managesieve"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"bincode", "bincode",
@ -3947,7 +3947,7 @@ dependencies = [
[[package]] [[package]]
name = "nlp" name = "nlp"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"bincode", "bincode",
@ -4498,7 +4498,7 @@ dependencies = [
[[package]] [[package]]
name = "pop3" name = "pop3"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"common", "common",
"imap", "imap",
@ -6050,7 +6050,7 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]] [[package]]
name = "smtp" name = "smtp"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"bincode", "bincode",
@ -6166,7 +6166,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]] [[package]]
name = "stalwart-cli" name = "stalwart-cli"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"clap", "clap",
"console", "console",
@ -6197,7 +6197,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "store" name = "store"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"arc-swap", "arc-swap",
@ -6824,7 +6824,7 @@ dependencies = [
[[package]] [[package]]
name = "trc" name = "trc"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"base64 0.22.1", "base64 0.22.1",
@ -7067,7 +7067,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "utils" name = "utils"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"base64 0.22.1", "base64 0.22.1",

View file

@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"]
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
repository = "https://github.com/stalwartlabs/cli" repository = "https://github.com/stalwartlabs/cli"
homepage = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
readme = "README.md" readme = "README.md"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -97,6 +97,7 @@ pub struct Auth {
pub struct Mail { pub struct Mail {
pub script: IfBlock, pub script: IfBlock,
pub rewrite: IfBlock, pub rewrite: IfBlock,
pub is_allowed: IfBlock,
} }
#[derive(Clone)] #[derive(Clone)]
@ -366,6 +367,11 @@ impl SessionConfig {
"session.mail.rewrite", "session.mail.rewrite",
&has_sender_vars, &has_sender_vars,
), ),
(
&mut session.mail.is_allowed,
"session.mail.is-allowed",
&has_sender_vars,
),
( (
&mut session.rcpt.script, &mut session.rcpt.script,
"session.rcpt.script", "session.rcpt.script",
@ -761,6 +767,11 @@ impl Default for SessionConfig {
mail: Mail { mail: Mail {
script: IfBlock::empty("session.mail.script"), script: IfBlock::empty("session.mail.script"),
rewrite: IfBlock::empty("session.mail.rewrite"), 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 { rcpt: Rcpt {
script: IfBlock::empty("session.rcpt.script"), script: IfBlock::empty("session.rcpt.script"),

View file

@ -291,10 +291,10 @@ impl Core {
if let Err(err) = result { if let Err(err) = result {
Err(err) Err(err)
} else if self.has_fail2ban() { } else if self.has_auth_fail2ban() {
let login = credentials.login(); let login = credentials.login();
if self.is_fail2banned(remote_ip, login.to_string()).await? { if self.is_auth_fail2banned(remote_ip, login).await? {
Err(trc::AuthEvent::Banned Err(trc::SecurityEvent::AuthenticationBan
.into_err() .into_err()
.ctx(trc::Key::RemoteIp, remote_ip) .ctx(trc::Key::RemoteIp, remote_ip)
.ctx(trc::Key::AccountName, login.to_string())) .ctx(trc::Key::AccountName, login.to_string()))

View file

@ -21,7 +21,9 @@ pub struct BlockedIps {
pub version: AtomicU8, pub version: AtomicU8,
ip_networks: Vec<IpAddrMask>, ip_networks: Vec<IpAddrMask>,
has_networks: bool, has_networks: bool,
limiter_rate: Option<Rate>, auth_fail_rate: Option<Rate>,
rcpt_fail_rate: Option<Rate>,
loiter_fail_rate: Option<Rate>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -63,7 +65,15 @@ impl BlockedIps {
ip_addresses: RwLock::new(ip_addresses), ip_addresses: RwLock::new(ip_addresses),
has_networks: !ip_networks.is_empty(), has_networks: !ip_networks.is_empty(),
ip_networks, 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(), version: 0.into(),
} }
} }
@ -108,46 +118,86 @@ impl AllowedIps {
} }
impl Core { impl Core {
pub async fn is_fail2banned(&self, ip: IpAddr, login: String) -> trc::Result<bool> { pub async fn is_rcpt_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
if let Some(rate) = &self.network.blocked_ips.limiter_rate { if let Some(rate) = &self.network.blocked_ips.rcpt_fail_rate {
let is_allowed = self.is_ip_allowed(&ip) let is_allowed = self.is_ip_allowed(&ip)
|| (self || self
.storage .storage
.lookup .lookup
.is_rate_allowed(format!("b:{}", ip).as_bytes(), rate, false) .is_rate_allowed(format!("r:{ip}").as_bytes(), rate, false)
.await? .await?
.is_none() .is_none();
&& self
.storage
.lookup
.is_rate_allowed(format!("b:{}", login).as_bytes(), rate, false)
.await?
.is_none());
if !is_allowed { if !is_allowed {
// Add IP to blocked list return self.block_ip(ip).await.map(|_| true);
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);
} }
} }
Ok(false) Ok(false)
} }
pub fn has_fail2ban(&self) -> bool { pub async fn is_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
self.network.blocked_ips.limiter_rate.is_some() 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)
.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 { pub fn is_ip_blocked(&self, ip: &IpAddr) -> bool {
@ -186,8 +236,10 @@ impl Default for BlockedIps {
ip_addresses: RwLock::new(AHashSet::new()), ip_addresses: RwLock::new(AHashSet::new()),
ip_networks: Default::default(), ip_networks: Default::default(),
has_networks: Default::default(), has_networks: Default::default(),
limiter_rate: Default::default(),
version: 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_addresses: RwLock::new(self.ip_addresses.read().clone()),
ip_networks: self.ip_networks.clone(), ip_networks: self.ip_networks.clone(),
has_networks: self.has_networks, has_networks: self.has_networks,
limiter_rate: self.limiter_rate.clone(),
version: self version: self
.version .version
.load(std::sync::atomic::Ordering::Relaxed) .load(std::sync::atomic::Ordering::Relaxed)
.into(), .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") f.debug_struct("BlockedIps")
.field("ip_addresses", &self.ip_addresses) .field("ip_addresses", &self.ip_addresses)
.field("ip_networks", &self.ip_networks) .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() .finish()
} }
} }

View file

@ -230,7 +230,7 @@ impl BuildSession for Arc<ServerInstance> {
// Check if blocked // Check if blocked
if core.is_ip_blocked(&remote_ip) { if core.is_ip_blocked(&remote_ip) {
trc::event!( trc::event!(
Network(trc::NetworkEvent::DropBlocked), Security(trc::SecurityEvent::IpBlocked),
ListenerId = self.id.clone(), ListenerId = self.id.clone(),
LocalPort = local_addr.port(), LocalPort = local_addr.port(),
RemoteIp = remote_ip, RemoteIp = remote_ip,

View file

@ -105,8 +105,10 @@ impl MetricsStore for Store {
EventType::MessageIngest(MessageIngestEvent::Ham), EventType::MessageIngest(MessageIngestEvent::Ham),
EventType::MessageIngest(MessageIngestEvent::Spam), EventType::MessageIngest(MessageIngestEvent::Spam),
EventType::Auth(AuthEvent::Failed), EventType::Auth(AuthEvent::Failed),
EventType::Auth(AuthEvent::Banned), EventType::Security(SecurityEvent::AuthenticationBan),
EventType::Network(NetworkEvent::DropBlocked), EventType::Security(SecurityEvent::BruteForceBan),
EventType::Security(SecurityEvent::LoiterBan),
EventType::Security(SecurityEvent::IpBlocked),
EventType::IncomingReport(IncomingReportEvent::DmarcReport), EventType::IncomingReport(IncomingReportEvent::DmarcReport),
EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings),
EventType::IncomingReport(IncomingReportEvent::TlsReport), EventType::IncomingReport(IncomingReportEvent::TlsReport),

View file

@ -418,12 +418,12 @@ impl StoreTracer {
AuthEvent::Success AuthEvent::Success
| AuthEvent::Failed | AuthEvent::Failed
| AuthEvent::TooManyAttempts | AuthEvent::TooManyAttempts
| AuthEvent::Banned
| AuthEvent::Error | AuthEvent::Error
) )
| EventType::Sieve(_) | EventType::Sieve(_)
| EventType::Milter(_) | EventType::Milter(_)
| EventType::MtaHook(_) | EventType::MtaHook(_)
| EventType::Security(_)
) )
}) })
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "directory" name = "directory"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "imap" name = "imap"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "jmap" name = "jmap"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -885,11 +885,10 @@ impl ToRequestError for trc::Error {
trc::AuthEvent::MissingTotp => { trc::AuthEvent::MissingTotp => {
RequestError::blank(403, "TOTP code required", cause.message()) RequestError::blank(403, "TOTP code required", cause.message())
} }
trc::AuthEvent::TooManyAttempts | trc::AuthEvent::Banned => { trc::AuthEvent::TooManyAttempts => RequestError::too_many_auth_attempts(),
RequestError::too_many_auth_attempts()
}
_ => RequestError::unauthorized(), _ => RequestError::unauthorized(),
}, },
trc::EventType::Security(_) => RequestError::too_many_auth_attempts(),
trc::EventType::Resource(cause) => match cause { trc::EventType::Resource(cause) => match cause {
trc::ResourceEvent::NotFound => RequestError::not_found(), trc::ResourceEvent::NotFound => RequestError::not_found(),
trc::ResourceEvent::BadParameters => RequestError::blank( trc::ResourceEvent::BadParameters => RequestError::blank(

View file

@ -7,7 +7,7 @@ homepage = "https://stalw.art"
keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] keywords = ["imap", "jmap", "smtp", "email", "mail", "server"]
categories = ["email"] categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "managesieve" name = "managesieve"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "nlp" name = "nlp"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "pop3" name = "pop3"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"] keywords = ["smtp", "email", "mail", "server"]
categories = ["email"] categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -206,7 +206,7 @@ impl<T: SessionStream> Session<T> {
) )
.await; .await;
} }
trc::EventType::Auth(trc::AuthEvent::Banned) => { trc::EventType::Security(_) => {
return Err(()); return Err(());
} }
_ => (), _ => (),

View file

@ -120,6 +120,29 @@ impl<T: SessionStream> Session<T> {
} }
.into(); .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 // Sieve filtering
if let Some((script, script_id)) = self if let Some((script, script_id)) = self
.core .core

View file

@ -8,7 +8,7 @@ use common::{config::smtp::session::Stage, listener::SessionStream, scripts::Scr
use smtp_proto::{ use smtp_proto::{
RcptTo, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, RcptTo, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
}; };
use trc::SmtpEvent; use trc::{SecurityEvent, SmtpEvent};
use crate::{ use crate::{
core::{Session, SessionAddress}, core::{Session, SessionAddress},
@ -315,11 +315,33 @@ impl<T: SessionStream> Session<T> {
if self.data.rcpt_errors < self.params.rcpt_errors_max { if self.data.rcpt_errors < self.params.rcpt_errors_max {
Ok(()) Ok(())
} else { } else {
trc::event!( match self
Smtp(SmtpEvent::TooManyInvalidRcpt), .core
SpanId = self.data.session_id, .core
Limit = self.params.rcpt_errors_max, .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") self.write(b"421 4.3.0 Too many errors, disconnecting.\r\n")
.await?; .await?;

View file

@ -11,7 +11,7 @@ use common::{
listener::{self, SessionManager, SessionStream}, listener::{self, SessionManager, SessionStream},
}; };
use tokio_rustls::server::TlsStream; use tokio_rustls::server::TlsStream;
use trc::SmtpEvent; use trc::{SecurityEvent, SmtpEvent};
use crate::{ use crate::{
core::{Session, SessionData, SessionParameters, SmtpSessionManager, State}, core::{Session, SessionData, SessionParameters, SmtpSessionManager, State},
@ -194,10 +194,32 @@ impl<T: SessionStream> Session<T> {
.await .await
.ok(); .ok();
trc::event!( match self
Smtp(SmtpEvent::TimeLimitExceeded), .core
SpanId = self.data.session_id, .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; break;
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "store" name = "store"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "trc" name = "trc"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -50,6 +50,7 @@ impl EventType {
EventType::OutgoingReport(event) => event.description(), EventType::OutgoingReport(event) => event.description(),
EventType::Telemetry(event) => event.description(), EventType::Telemetry(event) => event.description(),
EventType::MessageIngest(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::OutgoingReport(event) => event.explain(),
EventType::Telemetry(event) => event.explain(), EventType::Telemetry(event) => event.explain(),
EventType::MessageIngest(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::MailFromUnauthorized => "MAIL FROM unauthorized",
SmtpEvent::MailFromRewritten => "MAIL FROM address rewritten", SmtpEvent::MailFromRewritten => "MAIL FROM address rewritten",
SmtpEvent::MailFromMissing => "MAIL FROM address missing", SmtpEvent::MailFromMissing => "MAIL FROM address missing",
SmtpEvent::MailFromNotAllowed => "MAIL FROM not allowed",
SmtpEvent::MailFrom => "SMTP MAIL FROM command", SmtpEvent::MailFrom => "SMTP MAIL FROM command",
SmtpEvent::MultipleMailFrom => "Multiple MAIL FROM commands", SmtpEvent::MultipleMailFrom => "Multiple MAIL FROM commands",
SmtpEvent::MailboxDoesNotExist => "Mailbox does not exist", SmtpEvent::MailboxDoesNotExist => "Mailbox does not exist",
@ -536,6 +539,9 @@ impl SmtpEvent {
SmtpEvent::MailFromMissing => { SmtpEvent::MailFromMissing => {
"The remote client issued an RCPT TO command before MAIL FROM" "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::MailFrom => "The remote client sent a MAIL FROM command",
SmtpEvent::MultipleMailFrom => "The remote client already 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", SmtpEvent::MailboxDoesNotExist => "The mailbox does not exist on the server",
@ -1114,7 +1120,6 @@ impl NetworkEvent {
NetworkEvent::Closed => "Network connection closed", NetworkEvent::Closed => "Network connection closed",
NetworkEvent::ProxyError => "Proxy protocol error", NetworkEvent::ProxyError => "Proxy protocol error",
NetworkEvent::SetOptError => "Network set option 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::Closed => "The network connection was closed",
NetworkEvent::ProxyError => "An error occurred with the proxy protocol", NetworkEvent::ProxyError => "An error occurred with the proxy protocol",
NetworkEvent::SetOptError => "An error occurred while setting network options", 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::Failed => "Authentication failed",
AuthEvent::MissingTotp => "Missing TOTP for authentication", AuthEvent::MissingTotp => "Missing TOTP for authentication",
AuthEvent::TooManyAttempts => "Too many authentication attempts", AuthEvent::TooManyAttempts => "Too many authentication attempts",
AuthEvent::Banned => "IP address banned after multiple authentication failures",
AuthEvent::Error => "Authentication error", AuthEvent::Error => "Authentication error",
} }
} }
@ -1747,9 +1750,6 @@ impl AuthEvent {
AuthEvent::Failed => "Failed authentication", AuthEvent::Failed => "Failed authentication",
AuthEvent::MissingTotp => "TOTP is missing for authentication", AuthEvent::MissingTotp => "TOTP is missing for authentication",
AuthEvent::TooManyAttempts => "Too many authentication attempts have been made", 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", 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",
}
}
}

View file

@ -126,6 +126,7 @@ impl EventType {
| SmtpEvent::MailFromRewritten | SmtpEvent::MailFromRewritten
| SmtpEvent::MailFromMissing | SmtpEvent::MailFromMissing
| SmtpEvent::MultipleMailFrom | SmtpEvent::MultipleMailFrom
| SmtpEvent::MailFromNotAllowed
| SmtpEvent::RcptToDuplicate | SmtpEvent::RcptToDuplicate
| SmtpEvent::RcptToRewritten | SmtpEvent::RcptToRewritten
| SmtpEvent::RcptToMissing | SmtpEvent::RcptToMissing
@ -203,9 +204,7 @@ impl EventType {
| NetworkEvent::FlushError | NetworkEvent::FlushError
| NetworkEvent::Closed => Level::Trace, | NetworkEvent::Closed => Level::Trace,
NetworkEvent::Timeout | NetworkEvent::AcceptError => Level::Debug, NetworkEvent::Timeout | NetworkEvent::AcceptError => Level::Debug,
NetworkEvent::ListenStart NetworkEvent::ListenStart | NetworkEvent::ListenStop => Level::Info,
| NetworkEvent::ListenStop
| NetworkEvent::DropBlocked => Level::Info,
NetworkEvent::ListenError NetworkEvent::ListenError
| NetworkEvent::BindError | NetworkEvent::BindError
| NetworkEvent::SetOptError | NetworkEvent::SetOptError
@ -228,7 +227,6 @@ impl EventType {
AuthEvent::Failed => Level::Debug, AuthEvent::Failed => Level::Debug,
AuthEvent::MissingTotp => Level::Trace, AuthEvent::MissingTotp => Level::Trace,
AuthEvent::TooManyAttempts => Level::Warn, AuthEvent::TooManyAttempts => Level::Warn,
AuthEvent::Banned => Level::Warn,
AuthEvent::Error => Level::Error, AuthEvent::Error => Level::Error,
AuthEvent::Success => Level::Info, AuthEvent::Success => Level::Info,
}, },
@ -275,9 +273,9 @@ impl EventType {
| PurgeEvent::TombstoneCleanup => Level::Debug, | PurgeEvent::TombstoneCleanup => Level::Debug,
}, },
EventType::Eval(event) => match event { EventType::Eval(event) => match event {
EvalEvent::Error => Level::Debug, EvalEvent::Error | EvalEvent::StoreNotFound => Level::Debug,
EvalEvent::Result => Level::Trace, EvalEvent::Result => Level::Trace,
EvalEvent::DirectoryNotFound | EvalEvent::StoreNotFound => Level::Warn, EvalEvent::DirectoryNotFound => Level::Warn,
}, },
EventType::Server(event) => match event { EventType::Server(event) => match event {
ServerEvent::Startup | ServerEvent::Shutdown | ServerEvent::Licensing => { ServerEvent::Startup | ServerEvent::Shutdown | ServerEvent::Licensing => {
@ -536,6 +534,7 @@ impl EventType {
| MessageIngestEvent::Duplicate => Level::Info, | MessageIngestEvent::Duplicate => Level::Info,
MessageIngestEvent::Error => Level::Error, MessageIngestEvent::Error => Level::Error,
}, },
EventType::Security(_) => Level::Info,
} }
} }
} }

View file

@ -155,17 +155,15 @@ impl Event<EventType> {
matches!( matches!(
self.inner, self.inner,
EventType::Network(_) EventType::Network(_)
| EventType::Auth(AuthEvent::TooManyAttempts | AuthEvent::Banned) | EventType::Auth(AuthEvent::TooManyAttempts)
| EventType::Limit(LimitEvent::ConcurrentRequest | LimitEvent::TooManyRequests) | EventType::Limit(LimitEvent::ConcurrentRequest | LimitEvent::TooManyRequests)
| EventType::Security(_)
) )
} }
#[inline(always)] #[inline(always)]
pub fn should_write_err(&self) -> bool { pub fn should_write_err(&self) -> bool {
!matches!( !matches!(self.inner, EventType::Network(_) | EventType::Security(_))
self.inner,
EventType::Network(_) | EventType::Auth(AuthEvent::Banned)
)
} }
pub fn corrupted_key(key: &[u8], value: Option<&[u8]>, caused_by: &'static str) -> Error { 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 { impl AuthEvent {
#[inline(always)] #[inline(always)]
pub fn ctx(self, key: Key, value: impl Into<Value>) -> Error { pub fn ctx(self, key: Key, value: impl Into<Value>) -> Error {
@ -346,7 +351,6 @@ impl AuthEvent {
"Try authenticating again using 'secret$totp_token'." "Try authenticating again using 'secret$totp_token'."
), ),
Self::TooManyAttempts => "Too many authentication attempts", Self::TooManyAttempts => "Too many authentication attempts",
Self::Banned => "Banned",
_ => "Authentication error", _ => "Authentication error",
} }
} }

View file

@ -525,14 +525,14 @@ impl EventType {
| HttpEvent::ResponseBody | HttpEvent::ResponseBody
| HttpEvent::XForwardedMissing, | HttpEvent::XForwardedMissing,
) => true, ) => true,
EventType::Network(NetworkEvent::Timeout | NetworkEvent::DropBlocked) => true, EventType::Network(NetworkEvent::Timeout) => true,
EventType::Security(_) => true,
EventType::Limit(_) => true, EventType::Limit(_) => true,
EventType::Manage(_) => false, EventType::Manage(_) => false,
EventType::Auth( EventType::Auth(
AuthEvent::Success AuthEvent::Success
| AuthEvent::Failed | AuthEvent::Failed
| AuthEvent::TooManyAttempts | AuthEvent::TooManyAttempts
| AuthEvent::Banned
| AuthEvent::Error, | AuthEvent::Error,
) => true, ) => true,
EventType::Config(_) => false, EventType::Config(_) => false,

View file

@ -182,6 +182,7 @@ pub enum EventType {
IncomingReport(IncomingReportEvent), IncomingReport(IncomingReportEvent),
OutgoingReport(OutgoingReportEvent), OutgoingReport(OutgoingReportEvent),
Telemetry(TelemetryEvent), Telemetry(TelemetryEvent),
Security(SecurityEvent),
} }
#[event_type] #[event_type]
@ -195,6 +196,14 @@ pub enum HttpEvent {
XForwardedMissing, XForwardedMissing,
} }
#[event_type]
pub enum SecurityEvent {
AuthenticationBan,
BruteForceBan,
LoiterBan,
IpBlocked,
}
#[event_type] #[event_type]
pub enum ClusterEvent { pub enum ClusterEvent {
PeerAlive, PeerAlive,
@ -371,6 +380,7 @@ pub enum SmtpEvent {
LhloExpected, LhloExpected,
MailFromUnauthenticated, MailFromUnauthenticated,
MailFromUnauthorized, MailFromUnauthorized,
MailFromNotAllowed,
MailFromRewritten, MailFromRewritten,
MailFromMissing, MailFromMissing,
MailFrom, MailFrom,
@ -639,7 +649,6 @@ pub enum NetworkEvent {
Closed, Closed,
ProxyError, ProxyError,
SetOptError, SetOptError,
DropBlocked,
} }
#[event_type] #[event_type]
@ -915,7 +924,6 @@ pub enum AuthEvent {
Failed, Failed,
MissingTotp, MissingTotp,
TooManyAttempts, TooManyAttempts,
Banned,
Error, Error,
} }

View file

@ -339,7 +339,7 @@ impl EventType {
EventType::Arc(ArcEvent::InvalidCv) => 30, EventType::Arc(ArcEvent::InvalidCv) => 30,
EventType::Arc(ArcEvent::InvalidInstance) => 31, EventType::Arc(ArcEvent::InvalidInstance) => 31,
EventType::Arc(ArcEvent::SealerNotFound) => 32, EventType::Arc(ArcEvent::SealerNotFound) => 32,
EventType::Auth(AuthEvent::Banned) => 33, EventType::Security(SecurityEvent::AuthenticationBan) => 33,
EventType::Auth(AuthEvent::Error) => 34, EventType::Auth(AuthEvent::Error) => 34,
EventType::Auth(AuthEvent::Failed) => 35, EventType::Auth(AuthEvent::Failed) => 35,
EventType::Auth(AuthEvent::MissingTotp) => 36, EventType::Auth(AuthEvent::MissingTotp) => 36,
@ -624,7 +624,7 @@ impl EventType {
EventType::Network(NetworkEvent::AcceptError) => 315, EventType::Network(NetworkEvent::AcceptError) => 315,
EventType::Network(NetworkEvent::BindError) => 316, EventType::Network(NetworkEvent::BindError) => 316,
EventType::Network(NetworkEvent::Closed) => 317, EventType::Network(NetworkEvent::Closed) => 317,
EventType::Network(NetworkEvent::DropBlocked) => 318, EventType::Security(SecurityEvent::IpBlocked) => 318,
EventType::Network(NetworkEvent::FlushError) => 319, EventType::Network(NetworkEvent::FlushError) => 319,
EventType::Network(NetworkEvent::ListenError) => 320, EventType::Network(NetworkEvent::ListenError) => 320,
EventType::Network(NetworkEvent::ListenStart) => 321, EventType::Network(NetworkEvent::ListenStart) => 321,
@ -855,6 +855,9 @@ impl EventType {
EventType::Tls(TlsEvent::NoCertificatesAvailable) => 546, EventType::Tls(TlsEvent::NoCertificatesAvailable) => 546,
EventType::Tls(TlsEvent::NotConfigured) => 547, EventType::Tls(TlsEvent::NotConfigured) => 547,
EventType::Telemetry(TelemetryEvent::Alert) => 548, 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)), 30 => Some(EventType::Arc(ArcEvent::InvalidCv)),
31 => Some(EventType::Arc(ArcEvent::InvalidInstance)), 31 => Some(EventType::Arc(ArcEvent::InvalidInstance)),
32 => Some(EventType::Arc(ArcEvent::SealerNotFound)), 32 => Some(EventType::Arc(ArcEvent::SealerNotFound)),
33 => Some(EventType::Auth(AuthEvent::Banned)), 33 => Some(EventType::Security(SecurityEvent::AuthenticationBan)),
34 => Some(EventType::Auth(AuthEvent::Error)), 34 => Some(EventType::Auth(AuthEvent::Error)),
35 => Some(EventType::Auth(AuthEvent::Failed)), 35 => Some(EventType::Auth(AuthEvent::Failed)),
36 => Some(EventType::Auth(AuthEvent::MissingTotp)), 36 => Some(EventType::Auth(AuthEvent::MissingTotp)),
@ -1196,7 +1199,7 @@ impl EventType {
315 => Some(EventType::Network(NetworkEvent::AcceptError)), 315 => Some(EventType::Network(NetworkEvent::AcceptError)),
316 => Some(EventType::Network(NetworkEvent::BindError)), 316 => Some(EventType::Network(NetworkEvent::BindError)),
317 => Some(EventType::Network(NetworkEvent::Closed)), 317 => Some(EventType::Network(NetworkEvent::Closed)),
318 => Some(EventType::Network(NetworkEvent::DropBlocked)), 318 => Some(EventType::Security(SecurityEvent::IpBlocked)),
319 => Some(EventType::Network(NetworkEvent::FlushError)), 319 => Some(EventType::Network(NetworkEvent::FlushError)),
320 => Some(EventType::Network(NetworkEvent::ListenError)), 320 => Some(EventType::Network(NetworkEvent::ListenError)),
321 => Some(EventType::Network(NetworkEvent::ListenStart)), 321 => Some(EventType::Network(NetworkEvent::ListenStart)),
@ -1449,6 +1452,9 @@ impl EventType {
546 => Some(EventType::Tls(TlsEvent::NoCertificatesAvailable)), 546 => Some(EventType::Tls(TlsEvent::NoCertificatesAvailable)),
547 => Some(EventType::Tls(TlsEvent::NotConfigured)), 547 => Some(EventType::Tls(TlsEvent::NotConfigured)),
548 => Some(EventType::Telemetry(TelemetryEvent::Alert)), 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, _ => None,
} }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "utils" name = "utils"
version = "0.9.2" version = "0.9.3"
edition = "2021" edition = "2021"
resolver = "2" resolver = "2"

View file

@ -406,9 +406,11 @@ pub async fn insert_test_metrics(core: Arc<Core>) {
EventType::Queue(QueueEvent::QueueReport), EventType::Queue(QueueEvent::QueueReport),
EventType::MessageIngest(MessageIngestEvent::Ham), EventType::MessageIngest(MessageIngestEvent::Ham),
EventType::MessageIngest(MessageIngestEvent::Spam), EventType::MessageIngest(MessageIngestEvent::Spam),
EventType::Auth(AuthEvent::Banned),
EventType::Auth(AuthEvent::Failed), 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::DmarcReport),
EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings), EventType::IncomingReport(IncomingReportEvent::DmarcReportWithWarnings),
EventType::IncomingReport(IncomingReportEvent::TlsReport), EventType::IncomingReport(IncomingReportEvent::TlsReport),

View file

@ -100,8 +100,10 @@ enable = true
implicit = false implicit = false
certificate = "default" certificate = "default"
[server.fail2ban]
authentication = "101/5s"
[authentication] [authentication]
fail2ban = "101/5s"
rate-limit = "100/2s" rate-limit = "100/2s"
[session.ehlo] [session.ehlo]

View file

@ -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'}, mt-priority = [{if = "remote_ip = '10.0.0.2'", then = 'nsep'},
{else = false}] {else = false}]
[session.mail]
is-allowed = "sender_domain != 'blocked.com'"
[session.data.limits] [session.data.limits]
size = [{if = "remote_ip = '10.0.0.2'", then = 2048}, size = [{if = "remote_ip = '10.0.0.2'", then = 2048},
{else = 1024}] {else = 1024}]
@ -70,8 +73,8 @@ enable = true
#[tokio::test] #[tokio::test]
async fn mail() { async fn mail() {
// Enable logging // Enable logging
crate::enable_logging(); crate::enable_logging();
let tmp_dir = TempDir::new("smtp_mail_test", true); let tmp_dir = TempDir::new("smtp_mail_test", true);
let mut config = Config::new(tmp_dir.update_config(CONFIG)).unwrap(); let mut config = Config::new(tmp_dir.update_config(CONFIG)).unwrap();
@ -115,9 +118,16 @@ async fn mail() {
.unwrap(); .unwrap();
session.response().assert_code("503 5.5.1"); 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.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap();
session.response().assert_code("250"); 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 session
.ingest(b"MAIL FROM:<bill@foobar.org>\r\n") .ingest(b"MAIL FROM:<bill@foobar.org>\r\n")
.await .await