Skip to content

Commit

Permalink
Detect and ban port scanners as well as other forms of abuse (closes #…
Browse files Browse the repository at this point in the history
  • Loading branch information
mdecimus committed Oct 8, 2024
1 parent 1561a60 commit 581533b
Show file tree
Hide file tree
Showing 14 changed files with 303 additions and 62 deletions.
91 changes: 84 additions & 7 deletions crates/common/src/listener/blocked.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>,
Expand Down Expand Up @@ -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,
Expand All @@ -86,18 +125,44 @@ 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
|| (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!("h:{ip}").as_bytes(), rate, false)
.await?
.is_none();

if !is_allowed {
Expand All @@ -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)
Expand Down Expand Up @@ -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(),
}
}
}
32 changes: 18 additions & 14 deletions crates/common/src/manager/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ enum Pattern {
Exclude(MatchType),
}

#[derive(Debug)]
enum MatchType {
#[derive(Debug, Clone)]
pub enum MatchType {
Equal(String),
StartsWith(String),
EndsWith(String),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 2 additions & 1 deletion crates/common/src/telemetry/metrics/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions crates/imap/src/core/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use imap_proto::{
receiver::{self, Request},
Command, ResponseType, StatusResponse,
};
use trc::SecurityEvent;

use super::{SelectedMailbox, Session, SessionData, State};

Expand Down Expand Up @@ -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;
}
Expand Down
72 changes: 51 additions & 21 deletions crates/jmap/src/api/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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())
}
}
Expand Down Expand Up @@ -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"));
}
}
}
}

Expand Down Expand Up @@ -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(),
Expand Down
Loading

0 comments on commit 581533b

Please sign in to comment.