Detect and ban port scanners as well as other forms of abuse (closes #820)

This commit is contained in:
mdecimus 2024-10-08 11:48:03 +02:00
parent 1561a603ab
commit 581533b09c
14 changed files with 303 additions and 62 deletions

View file

@ -7,13 +7,16 @@
use std::{fmt::Debug, net::IpAddr};
use ahash::AHashSet;
use utils::config::{
ipmask::{IpAddrMask, IpAddrOrMask},
utils::ParseValue,
Config, ConfigKey, Rate,
use utils::{
config::{
ipmask::{IpAddrMask, IpAddrOrMask},
utils::ParseValue,
Config, ConfigKey, Rate,
},
glob::GlobPattern,
};
use crate::Server;
use crate::{manager::config::MatchType, Server};
#[derive(Debug, Clone)]
pub struct Security {
@ -24,6 +27,9 @@ pub struct Security {
allowed_ip_networks: Vec<IpAddrMask>,
has_allowed_networks: bool,
http_banned_paths: Vec<MatchType>,
scanner_fail_rate: Option<Rate>,
auth_fail_rate: Option<Rate>,
rcpt_fail_rate: Option<Rate>,
loiter_fail_rate: Option<Rate>,
@ -71,6 +77,39 @@ impl Security {
let blocked = BlockedIps::parse(config);
// Parse blocked HTTP paths
let mut http_banned_paths = config
.values("server.fail2ban.http-banned-paths")
.filter_map(|(_, v)| {
let v = v.trim();
if !v.is_empty() {
MatchType::parse(v).into()
} else {
None
}
})
.collect::<Vec<_>>();
if http_banned_paths.is_empty() {
for pattern in [
"*.php*",
"*.cgi*",
"*.asp*",
"*/wp-*",
"*/php*",
"*/cgi-bin*",
"*xmlrpc*",
"*../*",
"*/..*",
"*joomla*",
"*wordpress*",
"*drupal*",
]
.iter()
{
http_banned_paths.push(MatchType::Matches(GlobPattern::compile(pattern, true)));
}
}
Security {
has_blocked_networks: !blocked.blocked_ip_networks.is_empty(),
blocked_ip_networks: blocked.blocked_ip_networks,
@ -86,17 +125,43 @@ impl Security {
loiter_fail_rate: config
.property_or_default::<Option<Rate>>("server.fail2ban.loitering", "150/1d")
.unwrap_or_default(),
http_banned_paths,
scanner_fail_rate: config
.property_or_default::<Option<Rate>>("server.fail2ban.scanner", "30/1d")
.unwrap_or_default(),
}
}
}
impl Server {
pub async fn is_rcpt_fail2banned(&self, ip: IpAddr) -> trc::Result<bool> {
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
.lookup_store()
.is_rate_allowed(format!("r:{ip}").as_bytes(), rate, false)
.await?
.is_none()
&& self
.lookup_store()
.is_rate_allowed(format!("r:{rcpt}").as_bytes(), rate, false)
.await?
.is_none());
if !is_allowed {
return self.block_ip(ip).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
.lookup_store()
.is_rate_allowed(format!("r:{ip}").as_bytes(), rate, false)
.is_rate_allowed(format!("h:{ip}").as_bytes(), rate, false)
.await?
.is_none();
@ -108,6 +173,16 @@ impl Server {
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).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)
@ -253,6 +328,8 @@ impl Default for Security {
auth_fail_rate: Default::default(),
rcpt_fail_rate: Default::default(),
loiter_fail_rate: Default::default(),
scanner_fail_rate: Default::default(),
http_banned_paths: Default::default(),
}
}
}

View file

@ -40,8 +40,8 @@ enum Pattern {
Exclude(MatchType),
}
#[derive(Debug)]
enum MatchType {
#[derive(Debug, Clone)]
pub enum MatchType {
Equal(String),
StartsWith(String),
EndsWith(String),
@ -469,17 +469,7 @@ impl Patterns {
if value.is_empty() {
continue;
}
let match_type = if value == "*" {
MatchType::All
} else if let Some(value) = value.strip_suffix('*') {
MatchType::StartsWith(value.to_string())
} else if let Some(value) = value.strip_prefix('*') {
MatchType::EndsWith(value.to_string())
} else if value.contains('*') {
MatchType::Matches(GlobPattern::compile(&value, false))
} else {
MatchType::Equal(value.to_string())
};
let match_type = MatchType::parse(&value);
cfg_local_patterns.push(if is_include {
Pattern::Include(match_type)
@ -541,7 +531,21 @@ impl Patterns {
}
impl MatchType {
fn matches(&self, value: &str) -> bool {
pub fn parse(value: &str) -> Self {
if value == "*" {
MatchType::All
} else if let Some(value) = value.strip_suffix('*') {
MatchType::StartsWith(value.to_string())
} else if let Some(value) = value.strip_prefix('*') {
MatchType::EndsWith(value.to_string())
} else if value.contains('*') {
MatchType::Matches(GlobPattern::compile(value, false))
} else {
MatchType::Equal(value.to_string())
}
}
pub fn matches(&self, value: &str) -> bool {
match self {
MatchType::Equal(pattern) => value == pattern,
MatchType::StartsWith(pattern) => value.starts_with(pattern),

View file

@ -106,7 +106,8 @@ impl MetricsStore for Store {
EventType::MessageIngest(MessageIngestEvent::Spam),
EventType::Auth(AuthEvent::Failed),
EventType::Security(SecurityEvent::AuthenticationBan),
EventType::Security(SecurityEvent::BruteForceBan),
EventType::Security(SecurityEvent::ScanBan),
EventType::Security(SecurityEvent::AbuseBan),
EventType::Security(SecurityEvent::LoiterBan),
EventType::Security(SecurityEvent::IpBlocked),
EventType::IncomingReport(IncomingReportEvent::DmarcReport),

View file

@ -14,6 +14,7 @@ use imap_proto::{
receiver::{self, Request},
Command, ResponseType, StatusResponse,
};
use trc::SecurityEvent;
use super::{SelectedMailbox, Session, SessionData, State};
@ -50,6 +51,34 @@ impl<T: SessionStream> Session<T> {
break;
}
Err(receiver::Error::Error { response }) => {
// Check for port scanners
if matches!(
(&self.state, response.key(trc::Key::Code)),
(
State::NotAuthenticated { .. },
Some(trc::Value::Static("PARSE"))
)
) {
match self.server.is_scanner_fail2banned(self.remote_addr).await {
Ok(true) => {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = self.session_id,
RemoteIp = self.remote_addr,
Reason = "Invalid IMAP command",
);
return SessionResult::Close;
}
Ok(false) => {}
Err(err) => {
trc::error!(err
.span_id(self.session_id)
.details("Failed to check for fail2ban"));
}
}
}
if !self.write_error(response).await {
return SessionResult::Close;
}

View file

@ -32,6 +32,7 @@ use jmap_proto::{
types::{blob::BlobId, id::Id},
};
use std::future::Future;
use trc::SecurityEvent;
use crate::{
api::management::enterprise::telemetry::TelemetryApi,
@ -253,12 +254,12 @@ impl ParseHttp for Server {
}
}
("mta-sts.txt", &Method::GET) => {
if let Some(policy) = self.build_mta_sts_policy() {
return Ok(Resource::new("text/plain", policy.to_string().into_bytes())
.into_http_response());
return if let Some(policy) = self.build_mta_sts_policy() {
Ok(Resource::new("text/plain", policy.to_string().into_bytes())
.into_http_response())
} else {
return Err(trc::ResourceEvent::NotFound.into_err());
}
Err(trc::ResourceEvent::NotFound.into_err())
};
}
("mail-v1.xml", &Method::GET) => {
return self.handle_autoconfig_request(&req).await;
@ -471,11 +472,9 @@ impl ParseHttp for Server {
let resource = self.inner.data.webadmin.get("logo.svg").await?;
return if !resource.is_empty() {
Ok(resource.into_http_response())
} else {
Err(trc::ResourceEvent::NotFound.into_err())
};
if !resource.is_empty() {
return Ok(resource.into_http_response());
}
// SPDX-SnippetEnd
}
@ -507,14 +506,23 @@ impl ParseHttp for Server {
.get(path.strip_prefix('/').unwrap_or(path))
.await?;
return if !resource.is_empty() {
Ok(resource.into_http_response())
} else {
Err(trc::ResourceEvent::NotFound.into_err())
};
if !resource.is_empty() {
return Ok(resource.into_http_response());
}
}
}
// Block dangerous URLs
let path = req.uri().path();
if self.is_http_banned_path(path, session.remote_ip).await? {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = session.session_id,
RemoteIp = session.remote_ip,
Path = path.to_string(),
);
}
Err(trc::ResourceEvent::NotFound.into_err())
}
}
@ -652,11 +660,32 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
.with_upgrades()
.await
{
trc::event!(
Http(trc::HttpEvent::Error),
SpanId = session.session_id,
Reason = http_err.to_string(),
);
match inner
.build_server()
.is_scanner_fail2banned(session.remote_ip)
.await
{
Ok(true) => {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = session.session_id,
RemoteIp = session.remote_ip,
Reason = http_err.to_string(),
);
}
Ok(false) => {
trc::event!(
Http(trc::HttpEvent::Error),
SpanId = session.session_id,
Reason = http_err.to_string(),
);
}
Err(err) => {
trc::error!(err
.span_id(session.session_id)
.details("Failed to check for fail2ban"));
}
}
}
}
@ -994,7 +1023,8 @@ impl ToRequestError for trc::Error {
},
trc::EventType::Security(cause) => match cause {
trc::SecurityEvent::AuthenticationBan
| trc::SecurityEvent::BruteForceBan
| trc::SecurityEvent::ScanBan
| trc::SecurityEvent::AbuseBan
| trc::SecurityEvent::LoiterBan
| trc::SecurityEvent::IpBlocked => RequestError::too_many_auth_attempts(),
trc::SecurityEvent::Unauthorized => RequestError::forbidden(),

View file

@ -9,7 +9,7 @@ use imap_proto::receiver::{self, Request};
use jmap_proto::types::{collection::Collection, property::Property};
use store::query::Filter;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use trc::AddContext;
use trc::{AddContext, SecurityEvent};
use super::{Command, ResponseCode, SerializeResponse, Session, State};
@ -46,6 +46,34 @@ impl<T: SessionStream> Session<T> {
break;
}
Err(receiver::Error::Error { response }) => {
// Check for port scanners
if matches!(
(&self.state, response.key(trc::Key::Code)),
(
State::NotAuthenticated { .. },
Some(trc::Value::Static("PARSE"))
)
) {
match self.server.is_scanner_fail2banned(self.remote_addr).await {
Ok(true) => {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = self.session_id,
RemoteIp = self.remote_addr,
Reason = "Invalid ManageSieve command",
);
return SessionResult::Close;
}
Ok(false) => {}
Err(err) => {
trc::error!(err
.span_id(self.session_id)
.details("Failed to check for fail2ban"));
}
}
}
if let Err(err) = self.write_error(response).await {
trc::error!(err.span_id(self.session_id));
return SessionResult::Close;

View file

@ -6,7 +6,7 @@
use common::listener::{SessionResult, SessionStream};
use mail_send::Credentials;
use trc::AddContext;
use trc::{AddContext, SecurityEvent};
use crate::{
protocol::{request::Error, Command, Mechanism},
@ -49,6 +49,27 @@ impl<T: SessionStream> Session<T> {
break;
}
Err(Error::Parse(err)) => {
// Check for port scanners
if matches!(&self.state, State::NotAuthenticated { .. },) {
match self.server.is_scanner_fail2banned(self.remote_addr).await {
Ok(true) => {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = self.session_id,
RemoteIp = self.remote_addr,
Reason = "Invalid POP3 command",
);
return SessionResult::Close;
}
Ok(false) => {}
Err(err) => {
trc::error!(err
.span_id(self.session_id)
.details("Failed to check for fail2ban"));
}
}
}
requests.push(Err(trc::Pop3Event::Error.into_err().details(err)));
}
}

View file

@ -209,9 +209,12 @@ impl<T: SessionStream> Session<T> {
To = rcpt.address_lcase.clone(),
);
self.data.rcpt_to.pop();
let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase;
return self
.rcpt_error(b"550 5.1.2 Mailbox does not exist.\r\n")
.rcpt_error(
b"550 5.1.2 Mailbox does not exist.\r\n",
rcpt_to,
)
.await;
}
}
@ -243,8 +246,10 @@ impl<T: SessionStream> Session<T> {
To = rcpt.address_lcase.clone(),
);
self.data.rcpt_to.pop();
return self.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n").await;
let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase;
return self
.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n", rcpt_to)
.await;
}
}
Err(err) => {
@ -275,8 +280,10 @@ impl<T: SessionStream> Session<T> {
To = rcpt.address_lcase.clone(),
);
self.data.rcpt_to.pop();
return self.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n").await;
let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase;
return self
.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n", rcpt_to)
.await;
}
if self.is_allowed().await {
@ -301,17 +308,22 @@ impl<T: SessionStream> Session<T> {
self.write(b"250 2.1.5 OK\r\n").await
}
async fn rcpt_error(&mut self, response: &[u8]) -> Result<(), ()> {
async fn rcpt_error(&mut self, response: &[u8], rcpt: String) -> Result<(), ()> {
tokio::time::sleep(self.params.rcpt_errors_wait).await;
self.data.rcpt_errors += 1;
let has_too_many_errors = self.data.rcpt_errors >= self.params.rcpt_errors_max;
match self.server.is_rcpt_fail2banned(self.data.remote_ip).await {
match self
.server
.is_rcpt_fail2banned(self.data.remote_ip, &rcpt)
.await
{
Ok(true) => {
trc::event!(
Security(SecurityEvent::BruteForceBan),
Security(SecurityEvent::AbuseBan),
SpanId = self.data.session_id,
RemoteIp = self.data.remote_ip,
To = rcpt,
);
}
Ok(false) => {
@ -320,6 +332,7 @@ impl<T: SessionStream> Session<T> {
Smtp(SmtpEvent::TooManyInvalidRcpt),
SpanId = self.data.session_id,
Limit = self.params.rcpt_errors_max,
To = rcpt,
);
}
}

View file

@ -17,7 +17,7 @@ use smtp_proto::{
*,
};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use trc::{NetworkEvent, SmtpEvent};
use trc::{NetworkEvent, SecurityEvent, SmtpEvent};
use crate::core::{Session, State};
@ -236,6 +236,32 @@ impl<T: SessionStream> Session<T> {
Err(err) => match err {
Error::NeedsMoreData { .. } => break 'outer,
Error::UnknownCommand | Error::InvalidResponse { .. } => {
// Check for port scanners
if !self.is_authenticated() {
match self
.server
.is_scanner_fail2banned(self.data.remote_ip)
.await
{
Ok(true) => {
trc::event!(
Security(SecurityEvent::ScanBan),
SpanId = self.data.session_id,
RemoteIp = self.data.remote_ip,
Reason = "Invalid SMTP command",
);
return Err(());
}
Ok(false) => {}
Err(err) => {
trc::error!(err
.span_id(self.data.session_id)
.details("Failed to check for fail2ban"));
}
}
}
trc::event!(
Smtp(SmtpEvent::InvalidCommand),
SpanId = self.data.session_id,

View file

@ -1787,9 +1787,10 @@ 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::AbuseBan => "Banned due to abuse",
SecurityEvent::LoiterBan => "Banned due to loitering",
SecurityEvent::IpBlocked => "Blocked IP address",
SecurityEvent::ScanBan => "Banned due to scan",
SecurityEvent::Unauthorized => "Unauthorized access",
}
}
@ -1799,9 +1800,10 @@ impl SecurityEvent {
SecurityEvent::AuthenticationBan => {
"IP address was banned due to multiple authentication errors"
}
SecurityEvent::BruteForceBan => {
"IP address was banned due to possible brute force attack"
SecurityEvent::AbuseBan => {
"IP address was banned due to abuse, such as RCPT TO attacks"
}
SecurityEvent::ScanBan => "IP address was banned due to exploit scanning",
SecurityEvent::LoiterBan => "IP address was banned due to multiple loitering events",
SecurityEvent::IpBlocked => "Rejected connection from blocked IP address",
SecurityEvent::Unauthorized => "Account does not have permission to access resource",

View file

@ -140,6 +140,12 @@ impl Event<EventType> {
self.inner == EventType::Store(StoreEvent::AssertValueFailed)
}
pub fn key(&self, key: Key) -> Option<&Value> {
self.keys
.iter()
.find_map(|(k, v)| if *k == key { Some(v) } else { None })
}
#[inline(always)]
pub fn is_jmap_method_error(&self) -> bool {
!matches!(

View file

@ -200,7 +200,8 @@ pub enum HttpEvent {
#[event_type]
pub enum SecurityEvent {
AuthenticationBan,
BruteForceBan,
AbuseBan,
ScanBan,
LoiterBan,
IpBlocked,
Unauthorized,

View file

@ -854,7 +854,7 @@ 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::AbuseBan) => 549,
EventType::Security(SecurityEvent::LoiterBan) => 550,
EventType::Smtp(SmtpEvent::MailFromNotAllowed) => 551,
EventType::Security(SecurityEvent::Unauthorized) => 552,
@ -863,6 +863,7 @@ impl EventType {
EventType::Auth(AuthEvent::ClientRegistration) => 555,
EventType::Ai(AiEvent::LlmResponse) => 556,
EventType::Ai(AiEvent::ApiError) => 557,
EventType::Security(SecurityEvent::ScanBan) => 558,
}
}
@ -1457,7 +1458,7 @@ 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)),
549 => Some(EventType::Security(SecurityEvent::AbuseBan)),
550 => Some(EventType::Security(SecurityEvent::LoiterBan)),
551 => Some(EventType::Smtp(SmtpEvent::MailFromNotAllowed)),
552 => Some(EventType::Security(SecurityEvent::Unauthorized)),
@ -1466,6 +1467,7 @@ impl EventType {
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
556 => Some(EventType::Ai(AiEvent::LlmResponse)),
557 => Some(EventType::Ai(AiEvent::ApiError)),
558 => Some(EventType::Security(SecurityEvent::ScanBan)),
_ => None,
}
}

View file

@ -459,7 +459,8 @@ pub async fn insert_test_metrics(core: Arc<Core>) {
EventType::MessageIngest(MessageIngestEvent::Spam),
EventType::Auth(AuthEvent::Failed),
EventType::Security(SecurityEvent::AuthenticationBan),
EventType::Security(SecurityEvent::BruteForceBan),
EventType::Security(SecurityEvent::ScanBan),
EventType::Security(SecurityEvent::AbuseBan),
EventType::Security(SecurityEvent::LoiterBan),
EventType::Security(SecurityEvent::IpBlocked),
EventType::IncomingReport(IncomingReportEvent::DmarcReport),