mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-11-28 00:56:45 +00:00
SYN flood, brute force fail2ban + session.mail.is-allowed expression (closes #482 closes #688 closes #609)
This commit is contained in:
parent
7e1b6bd06d
commit
36fd5797b7
35 changed files with 325 additions and 114 deletions
19
CHANGELOG.md
19
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.
|
||||
|
|
26
Cargo.lock
generated
26
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "common"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -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
|
||||
|| 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<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)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -418,12 +418,12 @@ impl StoreTracer {
|
|||
AuthEvent::Success
|
||||
| AuthEvent::Failed
|
||||
| AuthEvent::TooManyAttempts
|
||||
| AuthEvent::Banned
|
||||
| AuthEvent::Error
|
||||
)
|
||||
| EventType::Sieve(_)
|
||||
| EventType::Milter(_)
|
||||
| EventType::MtaHook(_)
|
||||
| EventType::Security(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "directory"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "imap"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "jmap"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "nlp"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "pop3"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -206,7 +206,7 @@ impl<T: SessionStream> Session<T> {
|
|||
)
|
||||
.await;
|
||||
}
|
||||
trc::EventType::Auth(trc::AuthEvent::Banned) => {
|
||||
trc::EventType::Security(_) => {
|
||||
return Err(());
|
||||
}
|
||||
_ => (),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "store"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "trc"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "utils"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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:<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
|
||||
|
|
Loading…
Reference in a new issue