Skip to content

Commit

Permalink
SYN flood, brute force fail2ban + session.mail.is-allowed expression (c…
Browse files Browse the repository at this point in the history
…loses #482 closes #688 closes #609)
  • Loading branch information
mdecimus committed Aug 29, 2024
1 parent 7e1b6bd commit 36fd579
Show file tree
Hide file tree
Showing 35 changed files with 318 additions and 107 deletions.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 13 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <[email protected]>"]
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"
Expand Down
2 changes: 1 addition & 1 deletion crates/common/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.9.2"
version = "0.9.3"
edition = "2021"
resolver = "2"

Expand Down
11 changes: 11 additions & 0 deletions crates/common/src/config/smtp/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub struct Auth {
pub struct Mail {
pub script: IfBlock,
pub rewrite: IfBlock,
pub is_allowed: IfBlock,
}

#[derive(Clone)]
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
6 changes: 3 additions & 3 deletions crates/common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
112 changes: 85 additions & 27 deletions crates/common/src/listener/blocked.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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(),
}
}
}
Expand All @@ -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()
}
}
2 changes: 1 addition & 1 deletion crates/common/src/listener/listen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 36fd579

Please sign in to comment.