diff --git a/Cargo.lock b/Cargo.lock index 3c5a212db..89e708f0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,17 +313,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-client-ip" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ba1af5b620232acf37f2eb6d22151ea465491e0b4c25f552d1990f64ec5a67" -dependencies = [ - "axum", - "client-ip", - "serde", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -896,10 +885,10 @@ dependencies = [ "assign", "async-trait", "axum", - "axum-client-ip", "axum-extra", "base64 0.22.1", "bytes", + "client-ip", "conduwuit_core", "conduwuit_macros", "conduwuit_service", @@ -1041,7 +1030,6 @@ version = "26.6.2" dependencies = [ "assign", "axum", - "axum-client-ip", "axum-server", "axum-server-dual-protocol", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 13eb3daea..eb9b471a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,8 +115,8 @@ git = "https://github.com/vinchona/axum-server-dual-protocol.git" rev = "ca6db055254255b74238673ce4135698e347d71c" # feat!: bump axum_server to 0.8.0 default-features = false -[workspace.dependencies.axum-client-ip] -version = "1.3" +[workspace.dependencies.client-ip] +version = "0.2" [workspace.dependencies.tower] version = "0.5.2" diff --git a/conduwuit-example.toml b/conduwuit-example.toml index 38451b35b..f8b062306 100644 --- a/conduwuit-example.toml +++ b/conduwuit-example.toml @@ -290,7 +290,7 @@ # #ip_lookup_strategy = 5 -# The source to use for discovering the real connecting client IP. +# The preferred source to use for getting the real client IP. # # Takes any of the following options: # @@ -301,16 +301,47 @@ # "true_client_ip" - `True-Client-Ip` header # "x_envoy_external_address" - `X-Envoy-External-Address` header # "x_real_ip" - `X-Real-Ip` header +# "direct" - the directly connected IP address (fallback for other +# options) # # Only set this if you are certain only your reverse proxy # will send the expected header. There is no "is the connecting IP allowed # to set this header" check; if the header selected is present, it is # used. # -# Defaults to the IP address actually making the connection. +# Ignored if `accepted_ip_sources` is set. +# +# If not set set, `direct` is used. Additionally, `direct` is used +# as a fallback if the chosen header is not found. # #request_ip_source = false +# The accepted source(s) for the real client IP. +# +# Takes any combination of the following options: +# +# "cf_connecting_ip" - `Cf-Connecting-Ip` header +# "cloudfront_viewer_address" - `CloudFront-Viewer-Address` header +# "fly_client_ip" - `Fly-Client-IP` header +# "x_forwarded_for" - rightmost value of the `X-Forwarded-For` header +# "true_client_ip" - `True-Client-Ip` header +# "x_envoy_external_address" - `X-Envoy-External-Address` header +# "x_real_ip" - `X-Real-Ip` header +# "direct" - the directly connected IP address +# +# Only set this if you are certain only your reverse proxy +# will send the expected header. There is no "is the connecting IP allowed +# to set this header" check; if the header selected is present, it is +# used. +# +# One of the configured sources must be available or the request is +# rejected. If you want a fallback (including `direct`!), you have to +# configure it. Priority is based on configuration order. +# +# If not set, `request_ip_source` configuration is used. +# +#accepted_ip_sources = [] + # Max request size for file uploads in bytes. Defaults to 20MB. # Also limits incoming federated media. # diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index 34fa951da..25a8c3369 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -64,11 +64,11 @@ zstd_compression = [ [dependencies] async-trait.workspace = true -axum-client-ip.workspace = true axum-extra.workspace = true axum.workspace = true base64.workspace = true bytes.workspace = true +client-ip.workspace = true conduwuit-core.workspace = true conduwuit-macros.workspace = true conduwuit-service.workspace = true diff --git a/src/api/client_ip.rs b/src/api/client_ip.rs index a739b654d..d477490e4 100644 --- a/src/api/client_ip.rs +++ b/src/api/client_ip.rs @@ -1,135 +1,212 @@ -use std::{ - convert::Infallible, - net::{IpAddr, Ipv4Addr, SocketAddr}, +use core::net::{IpAddr, SocketAddr}; +use std::ops::Deref; + +use axum::{ + extract::{ConnectInfo, FromRequestParts}, + response::{IntoResponse, Response}, }; +use conduwuit::{debug_info, debug_warn}; +use http::{HeaderMap, StatusCode, request::Parts}; +use service::Services; -use axum::extract::{ConnectInfo, FromRequestParts}; -use axum_client_ip::ClientIp as SourcedClientIp; -use conduwuit::debug_warn; -use http::request::Parts; +#[derive(Debug, PartialEq)] +pub enum ClientIpError { + Header(client_ip::Error), + Direct, +} -const UNKNOWN_IP: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); +impl IntoResponse for ClientIpError { + fn into_response(self) -> Response { + let text = match self { + | Self::Header(e) => format!("{e}"), + | Self::Direct => "Failed to extract IP from ConnectionInfo".to_owned(), + }; + (StatusCode::INTERNAL_SERVER_ERROR, text).into_response() + } +} -/// [`ClientIp`] extractor that falls back to the connection peer address -/// instead of rejecting the request when `request_ip_source` can't be resolved. -#[derive(Debug, Clone, Copy)] -pub(crate) struct ClientIp(pub IpAddr); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ClientIp(pub IpAddr); + +type PartsToIpFn = Box Result>; + +impl ClientIp { + fn from_header(func: T) -> PartsToIpFn + where + T: Fn(&HeaderMap) -> Result + 'static, + { + Box::new(move |parts: &Parts| { + func(&parts.headers) + .map(Self) + .map_err(ClientIpError::Header) + }) + } + + fn from_connection_info(parts: &Parts) -> Result { + parts + .extensions + .get::>() + .ok_or_else(|| ClientIpError::Direct) + .map(|ConnectInfo(addr)| Self(addr.ip())) + } + + fn for_source(source: &str) -> Option { + match source { + | "cf_connecting_ip" => Some(Self::from_header(client_ip::cf_connecting_ip)), + | "cloudfront_viewer_address" => + Some(Self::from_header(client_ip::cloudfront_viewer_address)), + | "fly_client_ip" => Some(Self::from_header(client_ip::fly_client_ip)), + | "x_forwarded_for" => Some(Self::from_header(client_ip::rightmost_x_forwarded_for)), + | "true_client_ip" => Some(Self::from_header(client_ip::true_client_ip)), + | "x_envoy_external_address" => + Some(Self::from_header(client_ip::x_envoy_external_address)), + | "x_real_ip" => Some(Self::from_header(client_ip::x_real_ip)), + | "direct" => Some(Box::new(Self::from_connection_info)), + | s => { + debug_warn!("Invalid client IP source option supplied, skipping: {s}"); + None + }, + } + } + + fn for_config( + parts: &Parts, + accepted_ip_sources: &[String], + request_ip_source: Option<&String>, + ) -> Result { + accepted_ip_sources + .iter() + .filter_map(|s| Self::for_source(s)) + .map(|f| f(parts)) + .reduce(Result::or) + .or_else(|| { + debug_info!( + "No (valid) options set in `accepted_ip_sources`; falling back to \ + `request_ip_source`" + ); + request_ip_source + .and_then(|s| Self::for_source(s)) + .map(|f| f(parts).or_else(|_| Self::from_connection_info(parts))) + }) + .unwrap_or_else(|| { + debug_info!( + "No (valid) options set in `accepted_ip_sources` or `request_ip_source`; \ + using peer address" + ); + Self::from_connection_info(parts) + }) + } +} impl FromRequestParts for ClientIp where - S: Sync, + S: Deref + Sync, { - type Rejection = Infallible; + type Rejection = ClientIpError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let ip = match SourcedClientIp::from_request_parts(parts, state).await { - | Ok(SourcedClientIp(ip)) => ip, - | Err(rejection) => { - debug_warn!( - %rejection, - "Could not resolve client IP from request_ip_source; using peer address" - ); - parts - .extensions - .get::>() - .map_or(UNKNOWN_IP, |ConnectInfo(addr)| addr.ip()) - }, - }; - - Ok(Self(ip)) + Self::for_config( + parts, + &state.config.accepted_ip_sources, + state.config.request_ip_source.as_ref(), + ) } } #[cfg(test)] mod tests { - use std::net::Ipv6Addr; - - use axum_client_ip::ClientIpSource; + use std::net::{Ipv4Addr, Ipv6Addr}; use super::*; - const PEER: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)), 8448); - const PEER_V6: SocketAddr = - SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0x2001, 0xDB8, 0, 0, 0, 0, 0, 1)), 8448); + const SOCKET_V4: IpAddr = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + const HEADER_V4: IpAddr = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)); - fn parts( - source: Option, - peer: Option, - headers: &[(&str, &str)], - ) -> Parts { + const SOCKET_V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xDB8, 0, 0, 0, 0, 0, 1)); + const HEADER_V6: IpAddr = IpAddr::V6(Ipv6Addr::new(0x2001, 0xDB8, 0, 0, 0, 0, 0, 2)); + + fn parts(peer: Option, headers: &[(&str, &str)]) -> Parts { let mut builder = http::Request::builder(); for (name, value) in headers { builder = builder.header(*name, *value); } let (mut parts, ()) = builder.body(()).unwrap().into_parts(); - if let Some(source) = source { - parts.extensions.insert(source); - } if let Some(peer) = peer { - parts.extensions.insert(ConnectInfo(peer)); + parts + .extensions + .insert(ConnectInfo(SocketAddr::new(peer, 8448))); } parts } - async fn extract(mut parts: Parts) -> IpAddr { - let ClientIp(ip) = ClientIp::from_request_parts(&mut parts, &()).await.unwrap(); - ip + fn extract( + parts: &Parts, + accepted_ip_sources: &[String], + request_ip_source: Option<&String>, + ) -> Result { + ClientIp::for_config(parts, accepted_ip_sources, request_ip_source).map(|ClientIp(ip)| ip) } #[tokio::test] - async fn resolves_from_configured_source() { - let parts = - parts(Some(ClientIpSource::XRealIp), Some(PEER), &[("x-real-ip", "203.0.113.5")]); + async fn resolves_from_configured_source_ipv4() { + let parts = parts(Some(SOCKET_V4), &[("x-real-ip", &HEADER_V4.to_string())]); - assert_eq!(extract(parts).await, IpAddr::V4(Ipv4Addr::new(203, 0, 113, 5))); + assert_eq!(extract(&parts, &["x_real_ip".to_owned()], None), Ok(HEADER_V4)); + assert_eq!(extract(&parts, &[], Some(&"x_real_ip".to_owned())), Ok(HEADER_V4)); } #[tokio::test] - async fn resolves_ipv6_from_configured_source() { - let parts = - parts(Some(ClientIpSource::XRealIp), Some(PEER), &[("x-real-ip", "2001:db8::2")]); + async fn resolves_from_configured_source_ipv6() { + let parts = parts(Some(SOCKET_V6), &[("x-real-ip", &HEADER_V6.to_string())]); + + assert_eq!(extract(&parts, &["x_real_ip".to_owned()], None), Ok(HEADER_V6)); + assert_eq!(extract(&parts, &[], Some(&"x_real_ip".to_owned())), Ok(HEADER_V6)); + } + + #[tokio::test] + async fn accepted_ip_sources_no_fall_back_to_peer_when_header_missing() { + let parts = parts(Some(SOCKET_V4), &[]); + + extract(&parts, &["x_real_ip".to_owned()], None).unwrap_err(); + } + + #[tokio::test] + async fn request_ip_source_falls_back_to_peer_when_header_missing() { + let parts = parts(Some(SOCKET_V4), &[]); + + assert_eq!(extract(&parts, &[], Some(&"x_real_ip".to_owned())), Ok(SOCKET_V4)); + } + + #[tokio::test] + async fn accepted_ip_sources_no_fall_back_to_peer_when_header_unparsable() { + let parts = parts(Some(SOCKET_V4), &[("x-real-ip", "not-an-ip")]); + + extract(&parts, &["x_real_ip".to_owned()], None).unwrap_err(); + } + + #[tokio::test] + async fn request_ip_source_falls_back_to_peer_when_header_unparsable() { + let parts = parts(Some(SOCKET_V4), &[("x-real-ip", "not-an-ip")]); + + assert_eq!(extract(&parts, &[], Some(&"x_real_ip".to_owned())), Ok(SOCKET_V4)); + } + + #[tokio::test] + async fn accepted_ip_sources_falls_back_when_configured() { + let parts = parts(Some(SOCKET_V4), &[]); assert_eq!( - extract(parts).await, - IpAddr::V6(Ipv6Addr::new(0x2001, 0xDB8, 0, 0, 0, 0, 0, 2)) + extract(&parts, &["x_real_ip".to_owned(), "direct".to_owned()], None), + Ok(SOCKET_V4) ); } #[tokio::test] - async fn falls_back_to_peer_when_header_missing() { - let parts = parts(Some(ClientIpSource::XRealIp), Some(PEER), &[]); + async fn falls_back_to_peer_no_configuration() { + let parts = parts(Some(SOCKET_V4), &[("x-real-ip", &HEADER_V4.to_string())]); - assert_eq!(extract(parts).await, PEER.ip()); - } - - #[tokio::test] - async fn falls_back_to_ipv6_peer_when_header_missing() { - let parts = parts(Some(ClientIpSource::XRealIp), Some(PEER_V6), &[]); - - assert_eq!(extract(parts).await, PEER_V6.ip()); - } - - #[tokio::test] - async fn falls_back_to_peer_when_header_unparsable() { - let parts = - parts(Some(ClientIpSource::XRealIp), Some(PEER), &[("x-real-ip", "not-an-ip")]); - - assert_eq!(extract(parts).await, PEER.ip()); - } - - #[tokio::test] - async fn falls_back_to_peer_when_source_unset() { - let parts = parts(None, Some(PEER), &[("x-real-ip", "203.0.113.5")]); - - assert_eq!(extract(parts).await, PEER.ip()); - } - - #[tokio::test] - async fn falls_back_to_unspecified_without_peer() { - let parts = parts(Some(ClientIpSource::XRealIp), None, &[]); - - assert_eq!(extract(parts).await, UNKNOWN_IP); + assert_eq!(extract(&parts, &[], None), Ok(SOCKET_V4)); } } diff --git a/src/api/mod.rs b/src/api/mod.rs index da9b1e715..c2e71ba8c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,7 +11,7 @@ pub mod client; pub mod router; pub mod server; -pub(crate) mod client_ip; +pub mod client_ip; pub mod admin; diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index bee20103b..663a90c0b 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -370,7 +370,7 @@ pub struct Config { #[serde(default = "default_ip_lookup_strategy")] pub ip_lookup_strategy: u8, - /// The source to use for discovering the real connecting client IP. + /// The preferred source to use for getting the real client IP. /// /// Takes any of the following options: /// @@ -381,16 +381,49 @@ pub struct Config { /// "true_client_ip" - `True-Client-Ip` header /// "x_envoy_external_address" - `X-Envoy-External-Address` header /// "x_real_ip" - `X-Real-Ip` header + /// "direct" - the directly connected IP address (fallback for other + /// options) /// /// Only set this if you are certain only your reverse proxy /// will send the expected header. There is no "is the connecting IP allowed /// to set this header" check; if the header selected is present, it is /// used. /// - /// Defaults to the IP address actually making the connection. + /// Ignored if `accepted_ip_sources` is set. + /// + /// If not set set, `direct` is used. Additionally, `direct` is used + /// as a fallback if the chosen header is not found. #[serde(default)] pub request_ip_source: Option, + /// The accepted source(s) for the real client IP. + /// + /// Takes any combination of the following options: + /// + /// "cf_connecting_ip" - `Cf-Connecting-Ip` header + /// "cloudfront_viewer_address" - `CloudFront-Viewer-Address` header + /// "fly_client_ip" - `Fly-Client-IP` header + /// "x_forwarded_for" - rightmost value of the `X-Forwarded-For` header + /// "true_client_ip" - `True-Client-Ip` header + /// "x_envoy_external_address" - `X-Envoy-External-Address` header + /// "x_real_ip" - `X-Real-Ip` header + /// "direct" - the directly connected IP address + /// + /// Only set this if you are certain only your reverse proxy + /// will send the expected header. There is no "is the connecting IP allowed + /// to set this header" check; if the header selected is present, it is + /// used. + /// + /// One of the configured sources must be available or the request is + /// rejected. If you want a fallback (including `direct`!), you have to + /// configure it. Priority is based on configuration order. + /// + /// If not set, `request_ip_source` configuration is used. + /// + /// default: [] + #[serde(default)] + pub accepted_ip_sources: Vec, + /// Max request size for file uploads in bytes. Defaults to 20MB. /// Also limits incoming federated media. /// diff --git a/src/router/Cargo.toml b/src/router/Cargo.toml index 6dfed9947..8ab421446 100644 --- a/src/router/Cargo.toml +++ b/src/router/Cargo.toml @@ -91,7 +91,6 @@ zstd_compression = [ ] [dependencies] -axum-client-ip.workspace = true axum-server-dual-protocol.workspace = true axum-server-dual-protocol.optional = true axum-server.workspace = true diff --git a/src/router/layers.rs b/src/router/layers.rs index bde1d5cca..aeec5e84c 100644 --- a/src/router/layers.rs +++ b/src/router/layers.rs @@ -1,11 +1,11 @@ use std::{any::Any, sync::Arc, time::Duration}; use axum::{ - Router, extract, - extract::{DefaultBodyLimit, FromRequestParts, MatchedPath}, + Router, + extract::{self, DefaultBodyLimit, FromRequestParts, MatchedPath, State}, }; -use axum_client_ip::{ClientIp, ClientIpSource}; use conduwuit::{Result, Server, debug, error}; +use conduwuit_api::client_ip::ClientIp; use conduwuit_service::{Services, state::Guard}; use http::{ HeaderValue, Method, StatusCode, @@ -47,26 +47,9 @@ pub(crate) fn build(services: &Arc) -> Result<(Router, Guard)> { ))] let layers = layers.layer(compression_layer(server)); - let client_ip_layer = match services - .config - .request_ip_source - .as_ref() - .map(AsRef::as_ref) - { - | Some("cf_connecting_ip") => ClientIpSource::CfConnectingIp, - | Some("cloudfront_viewer_address") => ClientIpSource::CloudFrontViewerAddress, - | Some("fly_client_ip") => ClientIpSource::FlyClientIp, - | Some("x_forwarded_for") => ClientIpSource::RightmostXForwardedFor, - | Some("true_client_ip") => ClientIpSource::TrueClientIp, - | Some("x_envoy_external_address") => ClientIpSource::XEnvoyExternalAddress, - | Some("x_real_ip") => ClientIpSource::XRealIp, - | None | Some(_) => ClientIpSource::ConnectInfo, - }; - let services_ = services.clone(); let layers = layers .layer(SetSensitiveHeadersLayer::new([header::AUTHORIZATION])) - .layer(client_ip_layer.into_extension()) .layer( TraceLayer::new_for_http() .make_span_with(tracing_span::<_>) @@ -74,7 +57,7 @@ pub(crate) fn build(services: &Arc) -> Result<(Router, Guard)> { .on_request(DefaultOnRequest::new().level(tracing::Level::TRACE)) .on_response(DefaultOnResponse::new().level(tracing::Level::DEBUG)), ) - .layer(axum::middleware::from_fn(request_ip)) + .layer(axum::middleware::from_fn_with_state(Arc::clone(services), request_ip)) .layer(axum::middleware::from_fn_with_state(Arc::clone(services), request::handle)) .layer(ResponseBodyTimeoutLayer::new(Duration::from_secs( server.config.client_response_timeout, @@ -238,11 +221,12 @@ fn tracing_span(request: &http::Request) -> tracing::Span { /// Annotates the tracing span with the client IP async fn request_ip( + State(services): State>, request: extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { let (mut parts, body) = request.into_parts(); - if let Ok(ip) = ClientIp::from_request_parts(&mut parts, &()).await { + if let Ok(ip) = ClientIp::from_request_parts(&mut parts, &services).await { let span = tracing::Span::current(); span.record("ip", ip.0.to_string()); }