From 0d99c2fc08620fcca32cd5c20e2a85d5e849934e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 10 Mar 2023 01:12:37 -0500 Subject: [PATCH] new ip address filter middleware (wip) --- Cargo.lock | 1 + crates/lib/actix_helpers/Cargo.toml | 3 + .../src/extractors/get_ip_from_header.rs | 37 +++++++++ .../get_service_request_ip_address.rs | 29 +++++++ .../lib/actix_helpers/src/extractors/mod.rs | 4 + crates/lib/actix_helpers/src/lib.rs | 10 ++- .../src/middleware/ip_filter/banned_error.rs | 31 ++++++++ .../src/middleware/ip_filter/ip_ban_list.rs | 75 ++++++++++++++++++ .../ip_filter/ip_filter_middleware.rs | 78 +++++++++++++++++++ .../src/middleware/ip_filter/ip_set.rs | 35 +++++++++ .../src/middleware/ip_filter/mod.rs | 4 + .../lib/actix_helpers/src/middleware/mod.rs | 1 + 12 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 crates/lib/actix_helpers/src/extractors/get_ip_from_header.rs create mode 100644 crates/lib/actix_helpers/src/extractors/get_service_request_ip_address.rs create mode 100644 crates/lib/actix_helpers/src/extractors/mod.rs create mode 100644 crates/lib/actix_helpers/src/middleware/ip_filter/banned_error.rs create mode 100644 crates/lib/actix_helpers/src/middleware/ip_filter/ip_ban_list.rs create mode 100644 crates/lib/actix_helpers/src/middleware/ip_filter/ip_filter_middleware.rs create mode 100644 crates/lib/actix_helpers/src/middleware/ip_filter/ip_set.rs create mode 100644 crates/lib/actix_helpers/src/middleware/ip_filter/mod.rs create mode 100644 crates/lib/actix_helpers/src/middleware/mod.rs diff --git a/Cargo.lock b/Cargo.lock index b5bb7a8ef4..19bc7ffd43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,6 +675,7 @@ dependencies = [ "actix-web 4.0.0-beta.10", "actix-web-actors", "errors", + "futures-util", ] [[package]] diff --git a/crates/lib/actix_helpers/Cargo.toml b/crates/lib/actix_helpers/Cargo.toml index db6a192d71..4e203a60c2 100644 --- a/crates/lib/actix_helpers/Cargo.toml +++ b/crates/lib/actix_helpers/Cargo.toml @@ -28,5 +28,8 @@ actix-utils = "3.0.0" actix-web = "4.0.0-beta.10" actix-web-actors = "4.0.0-beta.7" +# Futures +futures-util = "0.3.9" + [dev-dependencies] # None yet diff --git a/crates/lib/actix_helpers/src/extractors/get_ip_from_header.rs b/crates/lib/actix_helpers/src/extractors/get_ip_from_header.rs new file mode 100644 index 0000000000..64e171175f --- /dev/null +++ b/crates/lib/actix_helpers/src/extractors/get_ip_from_header.rs @@ -0,0 +1,37 @@ +use actix_http::http::HeaderMap; +use actix_web::http::HeaderName; +use std::str::FromStr; + +pub (crate) fn get_ip_from_header(headers: &HeaderMap, header_name: &str) -> Option { + if let Ok(header_name) = HeaderName::from_str(header_name) { + headers.get(&header_name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| first_ip_from_list(value)) + } else { + None + } +} + +// NB: GKE packs in multiple IP addresses into the header: +// Example of header: x-forwarded-for: Some("136.55.189.34, 34.117.9.171") +fn first_ip_from_list(ip_list: &str) -> Option { + ip_list.split(",") + .into_iter() + .map(|ip| ip.trim()) + .find(|ip| !ip.is_empty()) + .map(|ip| ip.to_string()) +} + +#[cfg(test)] +mod tests { + use crate::extractors::get_ip_from_header::first_ip_from_list; + + #[test] + fn test_first_ip() { + assert_eq!(first_ip_from_list("1.2.3.4"), Some("1.2.3.4".to_string())); + assert_eq!(first_ip_from_list("1.2.3.4, 34.117.9.171"), Some("1.2.3.4".to_string())); + assert_eq!(first_ip_from_list(", 1.2.3.4"), Some("1.2.3.4".to_string())); + assert_eq!(first_ip_from_list("1.2.3.4, "), Some("1.2.3.4".to_string())); + assert_eq!(first_ip_from_list(""), None); + } +} diff --git a/crates/lib/actix_helpers/src/extractors/get_service_request_ip_address.rs b/crates/lib/actix_helpers/src/extractors/get_service_request_ip_address.rs new file mode 100644 index 0000000000..726e825e32 --- /dev/null +++ b/crates/lib/actix_helpers/src/extractors/get_service_request_ip_address.rs @@ -0,0 +1,29 @@ +use actix_web::dev::ServiceRequest; +use crate::extractors::get_ip_from_header::get_ip_from_header; + +pub fn get_service_request_ip_address(request: &ServiceRequest) -> String { + let headers = request.headers(); + let maybe_x_forwarded = get_ip_from_header(headers, "x-forwarded-for"); + let maybe_forwarded = get_ip_from_header(headers, "forwarded"); + + let maybe_ip = maybe_x_forwarded.or(maybe_forwarded); + + match maybe_ip { + Some(ip_address) => ip_address, + None => { + // If we're running without the upstream Rust proxy, we can grab 'x-forwarded-for', which is + // populated by the DigitalOcean load balancer. + let ip_address_and_port = request.connection_info() + .remote_addr() + .unwrap_or("") + .to_string(); + let ip_address = ip_address_and_port.split(":") + .collect::>() + .get(0) + .copied() + .unwrap_or("") + .to_string(); + ip_address + }, + } +} diff --git a/crates/lib/actix_helpers/src/extractors/mod.rs b/crates/lib/actix_helpers/src/extractors/mod.rs new file mode 100644 index 0000000000..5c6463c210 --- /dev/null +++ b/crates/lib/actix_helpers/src/extractors/mod.rs @@ -0,0 +1,4 @@ +pub mod get_service_request_ip_address; + +// Utility +pub (crate) mod get_ip_from_header; diff --git a/crates/lib/actix_helpers/src/lib.rs b/crates/lib/actix_helpers/src/lib.rs index 37a114cf92..fcf4b32b0a 100644 --- a/crates/lib/actix_helpers/src/lib.rs +++ b/crates/lib/actix_helpers/src/lib.rs @@ -1,7 +1,7 @@ -//! errors +//! actix_helpers //! -//! The purpose of this library is to pin to a single 'anyhow' and also develop -//! common error utilities. +//! The purpose of this library is to collect utility functionality that may be useful +//! to reuse across Actix installs. //! // Never allow these @@ -18,4 +18,6 @@ #![allow(dead_code)] #![allow(non_snake_case)] -pub mod route_builder; \ No newline at end of file +pub mod extractors; +pub mod middleware; +pub mod route_builder; diff --git a/crates/lib/actix_helpers/src/middleware/ip_filter/banned_error.rs b/crates/lib/actix_helpers/src/middleware/ip_filter/banned_error.rs new file mode 100644 index 0000000000..a2cd5dc3e6 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/ip_filter/banned_error.rs @@ -0,0 +1,31 @@ +use actix_http::body::Body; +use actix_http::header::CONTENT_TYPE; +use actix_web::http::StatusCode; +use actix_web::http::header::ContentType; +use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError}; + +#[derive(Debug)] +pub struct BannedError {} + +impl std::fmt::Display for BannedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "BannedError") + } +} + +impl std::error::Error for BannedError {} + +impl ResponseError for BannedError { + fn status_code(&self) -> StatusCode { + StatusCode::UNAUTHORIZED + } + + fn error_response(&self) -> HttpResponse { + // NB: I'm setting a string error because I mistakenly got caught by this in local dev + // and couldn't figure out the issue for a bit. At least I can grep for this string. + // However, I need to balance this requirement with not cluing in those that are banned. + HttpResponseBuilder::new(self.status_code()) + .append_header((CONTENT_TYPE, ContentType::json())) + .body(r#"{\"success\": false, \"error_message\": \"ERR64: database error\"}"#) + } +} diff --git a/crates/lib/actix_helpers/src/middleware/ip_filter/ip_ban_list.rs b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_ban_list.rs new file mode 100644 index 0000000000..e6e22aa3b2 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_ban_list.rs @@ -0,0 +1,75 @@ +use crate::middleware::ip_filter::ip_set::IpSet; +use errors::{anyhow, AnyhowResult}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Clone)] +pub struct IpBanList { + ip_sets: Arc>> +} + +impl IpBanList { + pub fn new() -> Self { + Self { + ip_sets: Arc::new(RwLock::new(HashMap::new())) + } + } + + pub fn contains_ip_address>(&self, ip_address: S) -> AnyhowResult { + match self.ip_sets.read() { + Err(_) => Err(anyhow!("Can't read lock")), + Ok(sets) => { + for set in sets.values() { + if set.contains_ip_address(ip_address.as_ref()) { + return Ok(true) + } + } + Ok(false) + }, + } + } + + pub fn add_set(&self, set_name: String, ip_set: IpSet) -> AnyhowResult> { + match self.ip_sets.write() { + Err(_) => Err(anyhow!("Can't read lock")), + Ok(mut sets) => { + Ok(sets.insert(set_name, ip_set)) + }, + } + } + + pub fn remove_set(&self, set_name: &str) -> AnyhowResult> { + match self.ip_sets.write() { + Err(_) => Err(anyhow!("Can't read lock")), + Ok(mut sets) => { + Ok(sets.remove(set_name)) + }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::middleware::ip_filter::ip_ban_list::IpBanList; + use crate::middleware::ip_filter::ip_set::IpSet; + + #[test] + fn test_contains_ip() { + let mut local_ip_set = IpSet::new(); + local_ip_set.add_ip_address("127.0.0.1".to_string()); + local_ip_set.add_ip_address("192.168.0.1".to_string()); + + let mut remote_ip_set = IpSet::new(); + remote_ip_set.add_ip_address("4.4.4.4".to_string()); + remote_ip_set.add_ip_address("8.8.8.8".to_string()); + + let ip_ban_list = IpBanList::new(); + ip_ban_list.add_set("local".to_string(), local_ip_set).unwrap(); + ip_ban_list.add_set("remote".to_string(), remote_ip_set).unwrap(); + + assert_eq!(ip_ban_list.contains_ip_address("192.168.0.1").unwrap(), true); + assert_eq!(ip_ban_list.contains_ip_address("8.8.8.8").unwrap(), true); + + assert_eq!(ip_ban_list.contains_ip_address("1.2.3.4").unwrap(), false); + } +} diff --git a/crates/lib/actix_helpers/src/middleware/ip_filter/ip_filter_middleware.rs b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_filter_middleware.rs new file mode 100644 index 0000000000..2860b56d26 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_filter_middleware.rs @@ -0,0 +1,78 @@ +use actix_web::dev::{Service, Transform}; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::Error; +use crate::middleware::ip_filter::banned_error::BannedError; +use crate::middleware::ip_filter::ip_ban_list::IpBanList; +use futures_util::future::{err, ok, Either, Ready}; +use std::task::{Context, Poll}; +use crate::extractors::get_service_request_ip_address::get_service_request_ip_address; + +// There are two steps in middleware processing. +// 1. Middleware initialization, middleware factory gets called with +// next service in chain as parameter. +// 2. Middleware's call method gets called with normal request. + +#[derive(Clone)] +pub struct IpFilter { + ip_ban_list: IpBanList, +} + +impl IpFilter { + pub fn new(ip_ban_list: IpBanList) -> Self { + Self { + ip_ban_list, + } + } +} + +impl Transform for IpFilter + where + S: Service, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = IpFilterMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(IpFilterMiddleware { service, ip_ban_list: self.ip_ban_list.clone() }) + } +} + +pub struct IpFilterMiddleware { + service: S, + ip_ban_list: IpBanList, +} + +impl Service for IpFilterMiddleware + where + S: Service, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = Either>>; + + // alternatively(?), actix_service::forward_ready!(service); + fn poll_ready(&self, cx: &mut Context) -> Poll> { + self.service.poll_ready(cx) + } + + fn call(&self, request: ServiceRequest) -> Self::Future { + let ip_address = get_service_request_ip_address(&request); + + // NB: Fail open. + // We don't want our service to explode because we can't read bans. + let is_banned = self.ip_ban_list + .contains_ip_address(&ip_address) + .unwrap_or(false); + + if is_banned { + Either::Right(err(Error::from(BannedError {}))) + } else { + Either::Left(self.service.call(request)) + } + } +} diff --git a/crates/lib/actix_helpers/src/middleware/ip_filter/ip_set.rs b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_set.rs new file mode 100644 index 0000000000..2efbaf5f29 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/ip_filter/ip_set.rs @@ -0,0 +1,35 @@ +use std::collections::HashSet; + +#[derive(Clone)] +pub struct IpSet { + pub ip_set: HashSet, +} + +impl IpSet { + + pub fn new() -> Self { + Self { + ip_set: HashSet::new(), + } + } + + pub fn replace_set(&mut self, ip_set: HashSet) { + self.ip_set = ip_set; + } + + pub fn add_ip_address(&mut self, ip_address: String) -> bool { + self.ip_set.insert(ip_address) + } + + pub fn remove_ip_address(&mut self, ip_address: &str) -> bool { + self.ip_set.remove(ip_address) + } + + pub fn contains_ip_address>(&self, ip_address: S) -> bool { + self.ip_set.contains(ip_address.as_ref()) + } + + pub fn len(&self) -> usize { + self.ip_set.len() + } +} diff --git a/crates/lib/actix_helpers/src/middleware/ip_filter/mod.rs b/crates/lib/actix_helpers/src/middleware/ip_filter/mod.rs new file mode 100644 index 0000000000..11ecca58a3 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/ip_filter/mod.rs @@ -0,0 +1,4 @@ +pub mod banned_error; +pub mod ip_ban_list; +pub mod ip_filter_middleware; +pub mod ip_set; \ No newline at end of file diff --git a/crates/lib/actix_helpers/src/middleware/mod.rs b/crates/lib/actix_helpers/src/middleware/mod.rs new file mode 100644 index 0000000000..75cf1de358 --- /dev/null +++ b/crates/lib/actix_helpers/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod ip_filter; \ No newline at end of file