Files
Stalwart/crates/common/src/network/security.rs
2026-03-17 18:15:50 +01:00

484 lines
16 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
KV_RATE_LIMIT_AUTH, KV_RATE_LIMIT_LOITER, KV_RATE_LIMIT_RCPT, KV_RATE_LIMIT_SCAN, Server,
ipc::{BroadcastEvent, RegistryChange},
network::ip_to_bytes,
};
use ahash::AHashSet;
use registry::{
schema::{
enums::{BlockReason, PasswordHashAlgorithm, PasswordStrength},
prelude::{Object, ObjectType},
structs::{self, AllowedIp, BlockedIp, Rate, SystemSettings},
},
types::{datetime::UTCDateTime, ipmask::IpAddrOrMask},
};
use std::{fmt::Debug, hash::Hash, net::IpAddr};
use store::{
registry::{
bootstrap::Bootstrap,
write::{RegistryWrite, RegistryWriteResult},
},
write::now,
};
use trc::AddContext;
use types::id::Id;
use utils::glob::{GlobPattern, MatchType};
use zxcvbn::Score;
#[derive(Debug, Clone)]
pub struct Security {
pub allowed_ip_addresses: AHashSet<IpWithTtl<IpAddr>>,
pub allowed_ip_networks: Vec<IpWithTtl<IpAddrOrMask>>,
pub has_allowed_networks: bool,
pub blocked_ip_expiration: Option<u64>,
pub http_banned_paths: Vec<MatchType>,
pub scanner_fail_rate: Option<Rate>,
pub auth_fail_rate: Option<Rate>,
pub rcpt_fail_rate: Option<Rate>,
pub loiter_fail_rate: Option<Rate>,
pub default_role_ids_user: Vec<Id>,
pub default_role_ids_group: Vec<Id>,
pub default_role_ids_tenant: Vec<Id>,
pub default_role_ids_admin: Vec<Id>,
pub password_hash_algorithm: PasswordHashAlgorithm,
pub password_max_length: u32,
pub password_min_length: u32,
pub password_min_strength: Score,
pub password_default_expiration: Option<u64>,
}
#[derive(Default)]
pub struct BlockedIps {
pub blocked_ip_addresses: AHashSet<IpWithTtl<IpAddr>>,
pub blocked_ip_networks: Vec<IpWithTtl<IpAddrOrMask>>,
pub has_blocked_networks: bool,
}
#[derive(Debug, Clone)]
pub struct IpWithTtl<T: PartialEq + Eq + Hash> {
pub ip: T,
pub expires_at: u64,
}
impl Security {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut allowed_ip_addresses = AHashSet::new();
let mut allowed_ip_networks = Vec::new();
let mut expired_allows = Vec::new();
let now = now();
for ip in bp.list_infallible::<AllowedIp>().await {
let id = ip.id;
let revision = ip.revision;
let ip = ip.object;
let expires_at = ip
.expires_at
.as_ref()
.map(|dt| dt.timestamp() as u64)
.unwrap_or(u64::MAX);
if expires_at > now {
if let Some(ip) = ip.address.try_to_ip() {
allowed_ip_addresses.insert(IpWithTtl::new(ip, expires_at));
} else {
let ip_with_ttl = IpWithTtl::new(ip.address, expires_at);
if !allowed_ip_networks.contains(&ip_with_ttl) {
allowed_ip_networks.push(ip_with_ttl);
}
}
} else {
expired_allows.push((
id,
ip.address.clone(),
Object {
inner: ip.into(),
revision,
},
));
}
}
// Add proxy protocol IPs as allowed
let system = bp.setting_infallible::<SystemSettings>().await;
for ip in system.proxy_trusted_networks {
if let Some(ip) = ip.try_to_ip() {
allowed_ip_addresses.insert(IpWithTtl::new(ip, u64::MAX));
} else {
let ip_with_ttl = IpWithTtl::new(ip, u64::MAX);
if !allowed_ip_networks.contains(&ip_with_ttl) {
allowed_ip_networks.push(ip_with_ttl);
}
}
}
if !expired_allows.is_empty() {
for (id, _, object) in &expired_allows {
if let Err(err) = bp
.registry
.write(RegistryWrite::delete_object(*id, object))
.await
{
trc::error!(
err.details("Failed to delete expired allowed IP from registry.")
.caused_by(trc::location!())
);
}
}
trc::event!(
Security(trc::SecurityEvent::IpAllowExpired),
Details = expired_allows
.into_iter()
.map(|(_, ip, _)| trc::Value::from(ip.into_inner().0))
.collect::<Vec<_>>()
);
}
#[cfg(not(feature = "test_mode"))]
{
// Add loopback addresses
allowed_ip_addresses.insert(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
allowed_ip_addresses.insert(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
}
let security = bp.setting_infallible::<structs::Security>().await;
let auth = bp.setting_infallible::<structs::Authentication>().await;
Security {
has_allowed_networks: !allowed_ip_networks.is_empty(),
allowed_ip_addresses,
allowed_ip_networks,
blocked_ip_expiration: security.auth_ban_period.map(|v| v.as_secs()),
auth_fail_rate: security.auth_ban_rate,
rcpt_fail_rate: security.abuse_ban_rate,
loiter_fail_rate: security.loiter_ban_rate,
http_banned_paths: security
.scan_ban_paths
.iter()
.map(|pattern| MatchType::Matches(GlobPattern::compile(pattern, true)))
.collect(),
scanner_fail_rate: security.scan_ban_rate,
default_role_ids_user: auth.default_user_role_ids.into_inner(),
default_role_ids_group: auth.default_group_role_ids.into_inner(),
default_role_ids_tenant: auth.default_tenant_role_ids.into_inner(),
default_role_ids_admin: auth.default_admin_role_ids.into_inner(),
password_hash_algorithm: auth.password_hash_algorithm,
password_max_length: auth.password_max_length as u32,
password_min_length: auth.password_min_length as u32,
password_min_strength: match auth.password_min_strength {
PasswordStrength::Zero => Score::Zero,
PasswordStrength::One => Score::One,
PasswordStrength::Two => Score::Two,
PasswordStrength::Three => Score::Three,
PasswordStrength::Four => Score::Four,
},
password_default_expiration: auth.password_default_expiry.map(|v| v.as_secs()),
}
}
}
impl Server {
pub async fn is_rcpt_fail2banned(&self, ip: IpAddr, rcpt: &str) -> trc::Result<bool> {
if let Some(rate) = &self.core.network.security.rcpt_fail_rate {
let is_allowed = self.is_ip_allowed(ip)
|| (self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_RCPT, &ip_to_bytes(&ip), rate, false)
.await?
.is_none()
&& self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_RCPT, rcpt.as_bytes(), rate, false)
.await?
.is_none());
if !is_allowed {
return self
.block_ip(ip, BlockReason::RcptToFailure)
.await
.map(|_| true);
}
}
Ok(false)
}
pub async fn is_scanner_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
if let Some(rate) = &self.core.network.security.scanner_fail_rate {
let is_allowed = self.is_ip_allowed(ip)
|| self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_SCAN, &ip_to_bytes(&ip), rate, false)
.await?
.is_none();
if !is_allowed {
return self
.block_ip(ip, BlockReason::PortScanning)
.await
.map(|_| true);
}
}
Ok(false)
}
pub async fn is_http_banned_path(&self, path: &str, ip: IpAddr) -> trc::Result<bool> {
let paths = &self.core.network.security.http_banned_paths;
if !paths.is_empty() && paths.iter().any(|p| p.matches(path)) && !self.is_ip_allowed(ip) {
self.block_ip(ip, BlockReason::PortScanning)
.await
.map(|_| true)
} else {
Ok(false)
}
}
pub async fn is_loiter_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
if let Some(rate) = &self.core.network.security.loiter_fail_rate {
let is_allowed = self.is_ip_allowed(ip)
|| self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_LOITER, &ip_to_bytes(&ip), rate, false)
.await?
.is_none();
if !is_allowed {
return self
.block_ip(ip, BlockReason::Loitering)
.await
.map(|_| true);
}
}
Ok(false)
}
pub async fn is_auth_fail2banned(&self, ip: IpAddr, login: Option<&str>) -> trc::Result<bool> {
if let Some(rate) = &self.core.network.security.auth_fail_rate {
let login = login.unwrap_or_default();
let is_allowed = self.is_ip_allowed(ip)
|| (self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_AUTH, &ip_to_bytes(&ip), rate, false)
.await?
.is_none()
&& (login.is_empty()
|| self
.in_memory_store()
.is_rate_allowed(KV_RATE_LIMIT_AUTH, login.as_bytes(), rate, false)
.await?
.is_none()));
if !is_allowed {
return self
.block_ip(ip, BlockReason::AuthFailure)
.await
.map(|_| true);
}
}
Ok(false)
}
pub async fn block_ip(&self, ip: IpAddr, reason: BlockReason) -> trc::Result<()> {
// Add IP to blocked list
let now = now();
let expires_at = self
.core
.network
.security
.blocked_ip_expiration
.map(|v| now + v);
self.inner
.data
.blocked_ips
.write()
.blocked_ip_addresses
.insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX)));
// Write blocked IP to config
let RegistryWriteResult::Success(id) = self
.registry()
.write(RegistryWrite::insert(
&BlockedIp {
address: IpAddrOrMask::from_ip(ip),
created_at: UTCDateTime::from_timestamp(now as i64),
expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)),
reason,
}
.into(),
))
.await
.caused_by(trc::location!())?
else {
return Ok(());
};
// Increment version
self.cluster_broadcast(BroadcastEvent::RegistryChange(RegistryChange::Insert(
ObjectType::BlockedIp.id(id),
)))
.await;
Ok(())
}
pub fn has_auth_fail2ban(&self) -> bool {
self.core.network.security.auth_fail_rate.is_some()
}
pub fn is_ip_blocked(&self, ip: IpAddr) -> bool {
let blocked_ips = self.inner.data.blocked_ips.read();
(blocked_ips
.blocked_ip_addresses
.get(&IpWithTtl::new(ip, 0))
.is_some_and(|v| !v.is_expired())
|| (blocked_ips.has_blocked_networks
&& blocked_ips
.blocked_ip_networks
.iter()
.any(|network| network.ip.matches(&ip) && !network.is_expired())))
&& !self.is_ip_allowed(ip)
}
pub fn is_ip_allowed(&self, ip: IpAddr) -> bool {
self.core
.network
.security
.allowed_ip_addresses
.get(&IpWithTtl::new(ip, 0))
.is_some_and(|v| !v.is_expired())
|| (self.core.network.security.has_allowed_networks
&& self
.core
.network
.security
.allowed_ip_networks
.iter()
.any(|network| network.ip.matches(&ip) && !network.is_expired()))
}
pub fn is_secure_password(&self, password: &str, user_inputs: &[&str]) -> Result<(), String> {
if (password.len() as u32) > self.core.network.security.password_max_length {
Err(format!(
"Password must be at most {} characters long.",
self.core.network.security.password_max_length
))
} else if (password.len() as u32) < self.core.network.security.password_min_length {
Err(format!(
"Password must be at least {} characters long.",
self.core.network.security.password_min_length
))
} else if self.core.network.security.password_min_strength > Score::Zero {
let entropy = zxcvbn::zxcvbn(password, user_inputs);
if entropy.score() >= self.core.network.security.password_min_strength {
Ok(())
} else if let Some(feedback) = entropy.feedback() {
Err(format!("Password is too weak. {feedback}"))
} else {
Err("Password is too weak.".to_string())
}
} else {
Ok(())
}
}
}
impl BlockedIps {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let mut ips = Self::default();
let mut expired_blocks = Vec::new();
let now = now() as i64;
for ip in bp.list_infallible::<BlockedIp>().await {
let id = ip.id;
let revision = ip.revision;
let ip = ip.object;
let expires_at = ip
.expires_at
.as_ref()
.map(|dt| dt.timestamp() as u64)
.unwrap_or(u64::MAX);
if ip.expires_at.as_ref().is_none_or(|ip| ip.timestamp() > now) {
if let Some(ip) = ip.address.try_to_ip() {
ips.blocked_ip_addresses
.insert(IpWithTtl::new(ip, expires_at));
} else {
ips.blocked_ip_networks
.push(IpWithTtl::new(ip.address, expires_at));
}
} else {
expired_blocks.push((
id,
ip.address.clone(),
Object {
inner: ip.into(),
revision,
},
));
}
}
if !expired_blocks.is_empty() {
for (id, _, object) in &expired_blocks {
if let Err(err) = bp
.registry
.write(RegistryWrite::delete_object(*id, object))
.await
{
trc::error!(
err.details("Failed to delete expired blocked IP from registry.")
.caused_by(trc::location!())
);
}
}
trc::event!(
Security(trc::SecurityEvent::IpBlockExpired),
Details = expired_blocks
.into_iter()
.map(|(_, ip, _)| trc::Value::from(ip.into_inner().0))
.collect::<Vec<_>>()
);
}
ips.has_blocked_networks = !ips.blocked_ip_networks.is_empty();
ips
}
}
impl<T: PartialEq + Eq + Hash> Hash for IpWithTtl<T> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.ip.hash(state);
}
}
impl<T: PartialEq + Eq + Hash> PartialEq for IpWithTtl<T> {
fn eq(&self, other: &Self) -> bool {
self.ip == other.ip
}
}
impl<T: PartialEq + Eq + Hash> Eq for IpWithTtl<T> {}
impl<T: PartialEq + Eq + Hash> IpWithTtl<T> {
pub fn new(ip: T, expires_at: u64) -> Self {
Self { ip, expires_at }
}
pub fn is_expired(&self) -> bool {
self.expires_at <= now()
}
}