Detect and ban port scanners as well as other forms of abuse (closes #820)
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user