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