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/).
## [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
View file

@ -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",

View file

@ -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"

View file

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

View file

@ -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"),

View file

@ -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()))

View file

@ -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()
}
}

View file

@ -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,

View file

@ -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),

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

View file

@ -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"

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

@ -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

View file

@ -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?;

View file

@ -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;
}

View file

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

View file

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

View file

@ -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",
}
}
}

View file

@ -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,
}
}
}

View file

@ -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",
}
}

View file

@ -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,

View file

@ -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,
}

View file

@ -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,
}
}

View file

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

View file

@ -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),

View file

@ -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]

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'},
{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