├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit.sh ├── .rustfmt.toml ├── Cargo.toml ├── LICENSE ├── README.md ├── examples ├── configurable.rs └── tracing.rs └── src └── lib.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | 10 | [*.toml] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - ci 7 | pull_request: 8 | 9 | jobs: 10 | rustfmt: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Nightly Rust 17 | uses: dtolnay/rust-toolchain@master 18 | with: 19 | toolchain: nightly 20 | components: rustfmt 21 | 22 | - name: Rustfmt 23 | run: cargo +nightly fmt -- --check 24 | 25 | clippy: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Stable Rust 32 | uses: dtolnay/rust-toolchain@master 33 | with: 34 | toolchain: stable 35 | components: clippy 36 | 37 | - name: Clippy 38 | run: cargo clippy --all-targets --all-features -- -D warnings 39 | 40 | rustdoc: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | 46 | - name: Stable Rust 47 | uses: dtolnay/rust-toolchain@master 48 | with: 49 | toolchain: stable 50 | 51 | - name: Rustdoc 52 | run: cargo rustdoc --all-features -- -D warnings 53 | 54 | test: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Stable Rust 61 | uses: dtolnay/rust-toolchain@master 62 | with: 63 | toolchain: stable 64 | 65 | - name: Test default features 66 | run: cargo test --all-targets 67 | 68 | - name: Test forwarded-header feature 69 | run: cargo test --all-targets --features forwarded-header 70 | 71 | - name: Test docs 72 | run: cargo test --doc 73 | 74 | typos: 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v4 79 | 80 | - name: Check typos 81 | uses: crate-ci/typos@master 82 | with: 83 | files: . 84 | 85 | cargo_sort: 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Checkout 89 | uses: actions/checkout@v4 90 | 91 | - name: Stable Rust 92 | uses: dtolnay/rust-toolchain@master 93 | with: 94 | toolchain: stable 95 | 96 | - name: Install cargo-sort 97 | run: cargo install --locked cargo-sort 98 | 99 | - name: Check `Cargo.toml` sort 100 | run: cargo sort -c 101 | 102 | machete: 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v4 107 | 108 | - name: Stable Rust 109 | uses: dtolnay/rust-toolchain@master 110 | with: 111 | toolchain: stable 112 | 113 | - name: Install `cargo-machete` 114 | run: cargo install --locked cargo-machete 115 | 116 | - name: Check unused Cargo dependencies 117 | run: cargo machete 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.todo.md 2 | /target 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /.pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | # Linking the script as the pre-commit hook 6 | SCRIPT_PATH=$(realpath "$0") 7 | HOOK_PATH=$(git rev-parse --git-dir)/hooks/pre-commit 8 | if [ "$(realpath "$HOOK_PATH")" != "$SCRIPT_PATH" ]; then 9 | read -p "Link this script as the git pre-commit hook to avoid further manual running? (y/N): " answer 10 | if [[ $answer =~ ^[Yy]$ ]]; then 11 | ln -sf "$SCRIPT_PATH" "$HOOK_PATH" 12 | fi 13 | fi 14 | 15 | set -x 16 | 17 | # Install tools 18 | cargo clippy --version &>/dev/null || rustup component add clippy 19 | cargo machete --version &>/dev/null || cargo install --locked cargo-machete 20 | cargo sort --version &>/dev/null || cargo install --locked cargo-sort 21 | typos --version &>/dev/null || cargo install --locked typos-cli 22 | 23 | rustup toolchain list | grep -q 'nightly' || rustup toolchain install nightly 24 | cargo +nightly fmt --version &>/dev/null || rustup component add rustfmt --toolchain nightly 25 | 26 | # Checks 27 | typos . 28 | cargo machete 29 | cargo +nightly fmt -- --check 30 | cargo sort -c 31 | cargo clippy --all-targets --all-features -- -D warnings 32 | cargo rustdoc --all-features -- -D warnings 33 | 34 | cargo test --doc 35 | cargo test --all-targets 36 | cargo test --all-targets --features forwarded-header 37 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Crate" 3 | wrap_comments = true 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | description = "Client IP address extractors for Axum" 3 | edition = "2024" 4 | license = "MIT" 5 | name = "axum-client-ip" 6 | repository = "https://github.com/imbolc/axum-client-ip" 7 | version = "1.1.0" 8 | 9 | [features] 10 | default = ["serde"] 11 | forwarded-header = ["client-ip/forwarded-header"] 12 | serde = ["dep:serde"] 13 | 14 | [dependencies] 15 | axum = { version = "0.8", default-features = false, features = ["tokio"] } 16 | client-ip = "0.1" 17 | serde = { version = "1", features = ["derive"], optional = true } 18 | 19 | [dev-dependencies] 20 | axum = { version = "0.8", default-features = false, features = ["http1"] } 21 | envy = "0.4" 22 | http-body-util = "0.1" 23 | hyper = "1" 24 | tokio = { version = "1", features = ["full"] } 25 | tower = { version = "0.5", features = ["util"] } 26 | tower-http = { version = "0.6", features = ["trace"] } 27 | tracing = "0.1" 28 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 29 | 30 | [lints.rust] 31 | unsafe_code = "forbid" 32 | future_incompatible = { level = "deny", priority = -2 } 33 | keyword_idents = { level = "deny", priority = -2 } 34 | let_underscore = { level = "deny", priority = -2 } 35 | missing_docs = "deny" 36 | nonstandard_style = { level = "deny", priority = -2 } 37 | refining_impl_trait = { level = "deny", priority = -2 } 38 | rust_2018_compatibility = { level = "deny", priority = -2 } 39 | rust_2018_idioms = { level = "deny", priority = -2 } 40 | rust_2021_compatibility = { level = "deny", priority = -2 } 41 | rust_2024_compatibility = { level = "deny", priority = -2 } 42 | unreachable_pub = { level = "warn", priority = -1 } 43 | unused = { level = "warn", priority = -1 } 44 | 45 | [lints.clippy] 46 | all = { level = "warn", priority = -1 } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 imbolc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `axum-client-ip` 2 | 3 | [![License](https://img.shields.io/crates/l/axum-client-ip.svg)](https://choosealicense.com/licenses/mit/) 4 | [![Crates.io](https://img.shields.io/crates/v/axum-client-ip.svg)](https://crates.io/crates/axum-client-ip) 5 | [![Docs.rs](https://docs.rs/axum-client-ip/badge.svg)](https://docs.rs/axum-client-ip) 6 | 7 | Client IP address extractors for the [Axum] web framework. The crate is just a 8 | thin wrapper around a framework-independent [client-ip] crate. 9 | 10 | ## V1 breaking changes 11 | 12 | - Removed `InsecureClientIp` and related "leftmost" IP logic. The library now 13 | focuses solely on secure extraction based on trusted headers. 14 | - Renamed `SecureClientIp` to `ClientIp`. 15 | - Renamed `SecureClientIpSource` to `ClientIpSource`. 16 | 17 | The changes are triggered by 18 | ["rightmost" IP extraction bug](https://github.com/imbolc/axum-client-ip/issues/32). 19 | 20 | ## Configurable vs specific extractors 21 | 22 | There's a configurable [`ClientIp`] extractor you can use to make your 23 | application independent from a proxy it can run behind (if any) and also 24 | separate extractors for each proxy / source header. 25 | 26 | | Extractor / `ClientIpSource` Variant | Header Used | Typical Proxy / Service | 27 | | ------------------------------------ | --------------------------- | ------------------------------------------------------- | 28 | | `CfConnectingIp` | `CF-Connecting-IP` | Cloudflare | 29 | | `CloudFrontViewerAddress` | `CloudFront-Viewer-Address` | AWS CloudFront | 30 | | `FlyClientIp` | `Fly-Client-IP` | Fly.io | 31 | | `RightmostForwarded` | `Forwarded` | Proxies supporting RFC 7239 (extracts rightmost `for=`) | 32 | | `RightmostXForwardedFor` | `X-Forwarded-For` | Nginx, Apache, HAProxy, CDNs, LBs | 33 | | `TrueClientIp` | `True-Client-IP` | Cloudflare, Akamai | 34 | | `XRealIp` | `X-Real-Ip` | Nginx | 35 | | `ConnectInfo` | N/A (uses socket address) | No proxy, e.g. listening directly to 80 port | 36 | 37 | ## Configurable extractor 38 | 39 | The configurable extractor assumes initializing [`ClientIpSource`] at runtime 40 | (e.g. with an environment variable). This makes sense when you ship a 41 | pre-compiled binary, people meant to use in different environments. Here's an 42 | initialization [example]. 43 | 44 | ## Specific extractors 45 | 46 | Specific extractors don't require runtime initialization, but you'd have to 47 | recompile your binary when you change proxy server. 48 | 49 | ```rust,no_run 50 | // With the renaming, you have to change only one line when you change proxy 51 | use axum_client_ip::XRealIp as ClientIp; 52 | 53 | async fn handler(ClientIp(ip): ClientIp) { 54 | todo!() 55 | } 56 | ``` 57 | 58 | ## Contributing 59 | 60 | - please run [.pre-commit.sh] before sending a PR, it will check everything 61 | 62 | ## License 63 | 64 | This project is licensed under the [MIT license][license]. 65 | 66 | [.pre-commit.sh]: 67 | https://github.com/imbolc/axum-client-ip/blob/main/.pre-commit.sh 68 | [Axum]: https://github.com/tokio-rs/axum 69 | [client-ip]: https://github.com/imbolc/client-ip 70 | [example]: 71 | https://github.com/imbolc/axum-client-ip/blob/main/examples/configurable.rs 72 | [license]: https://github.com/imbolc/axum-client-ip/blob/main/LICENSE 73 | -------------------------------------------------------------------------------- /examples/configurable.rs: -------------------------------------------------------------------------------- 1 | //! An example of configuring `ClientIp` using an environment variable 2 | //! 3 | //! Don't forget to set the variable before running, e.g.: 4 | //! ```sh 5 | //! IP_SOURCE=ConnectInfo cargo run --example configurable 6 | //! ``` 7 | use std::net::SocketAddr; 8 | 9 | use axum::{Router, routing::get}; 10 | use axum_client_ip::{ClientIp, ClientIpSource}; 11 | 12 | #[derive(serde::Deserialize)] 13 | struct Config { 14 | ip_source: ClientIpSource, 15 | } 16 | 17 | async fn handler(ClientIp(ip): ClientIp) -> String { 18 | ip.to_string() 19 | } 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | let config: Config = envy::from_env().unwrap(); 24 | 25 | let app = Router::new() 26 | .route("/", get(handler)) 27 | // The line you're probably looking for :) 28 | .layer(config.ip_source.into_extension()); 29 | 30 | let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); 31 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 32 | 33 | println!("Listening on http://localhost:3000/"); 34 | axum::serve( 35 | listener, 36 | // Required for `ClientIpSource::ConnectInfo` 37 | app.into_make_service_with_connect_info::(), 38 | ) 39 | .await 40 | .unwrap() 41 | } 42 | -------------------------------------------------------------------------------- /examples/tracing.rs: -------------------------------------------------------------------------------- 1 | //! An example of integration with Tracing 2 | use std::net::SocketAddr; 3 | 4 | use axum::{ 5 | Router, 6 | extract::{self, FromRequestParts}, 7 | http::{self}, 8 | middleware::{self, Next}, 9 | routing::get, 10 | }; 11 | use axum_client_ip::{ClientIp, ClientIpSource}; 12 | use tokio::net::TcpListener; 13 | use tower::ServiceBuilder; 14 | use tower_http::trace::TraceLayer; 15 | use tracing::{Span, info, info_span, level_filters::LevelFilter}; 16 | use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; 17 | 18 | #[tokio::main] 19 | async fn main() { 20 | tracing_subscriber::registry() 21 | .with( 22 | EnvFilter::builder() 23 | .with_default_directive(LevelFilter::TRACE.into()) 24 | .from_env_lossy(), 25 | ) 26 | .with(fmt::layer()) 27 | .init(); 28 | 29 | let app = Router::new() 30 | .route( 31 | "/", 32 | get(async || { 33 | info!("hi"); 34 | "Hello, World!" 35 | }), 36 | ) 37 | .layer( 38 | ServiceBuilder::new() 39 | // Hardcode IP source, look into `examples/configurable.rs` for runtime 40 | // configuration 41 | .layer(ClientIpSource::ConnectInfo.into_extension()) 42 | // Create a request span with a placeholder for IP 43 | .layer( 44 | TraceLayer::new_for_http().make_span_with(|request: &http::Request<_>| { 45 | info_span!( 46 | "request", 47 | method = %request.method(), 48 | uri = %request.uri(), 49 | ip = tracing::field::Empty 50 | ) 51 | }), 52 | ) 53 | // Extract IP and fill the span placeholder 54 | .layer(middleware::from_fn( 55 | async |request: extract::Request, next: Next| { 56 | let (mut parts, body) = request.into_parts(); 57 | if let Ok(ip) = ClientIp::from_request_parts(&mut parts, &()).await { 58 | let span = Span::current(); 59 | span.record("ip", ip.0.to_string()); 60 | } 61 | next.run(extract::Request::from_parts(parts, body)).await 62 | }, 63 | )), 64 | ); 65 | 66 | let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); 67 | let listener = TcpListener::bind(&addr).await.unwrap(); 68 | 69 | println!("Listening on http://localhost:3000/"); 70 | axum::serve( 71 | listener, 72 | app.into_make_service_with_connect_info::(), 73 | ) 74 | .await 75 | .unwrap() 76 | } 77 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | use std::{ 3 | error::Error, 4 | fmt, 5 | marker::Sync, 6 | net::{IpAddr, SocketAddr}, 7 | str::FromStr, 8 | }; 9 | 10 | use axum::{ 11 | extract::{ConnectInfo, Extension, FromRequestParts}, 12 | http::{StatusCode, request::Parts}, 13 | response::{IntoResponse, Response}, 14 | }; 15 | use serde::{Deserialize, Serialize}; 16 | 17 | /// Defines an extractor 18 | macro_rules! define_extractor { 19 | ( 20 | $(#[$meta:meta])* 21 | $newtype:ident, 22 | $extractor:path 23 | ) => { 24 | $(#[$meta])* 25 | #[derive(Debug, Clone, Copy)] 26 | pub struct $newtype(pub std::net::IpAddr); 27 | 28 | impl $newtype { 29 | fn ip_from_headers(headers: &axum::http::HeaderMap) -> Result { 30 | Ok($extractor(&headers)?) 31 | } 32 | } 33 | 34 | impl axum::extract::FromRequestParts for $newtype 35 | where 36 | S: Sync, 37 | { 38 | type Rejection = Rejection; 39 | 40 | async fn from_request_parts( 41 | parts: &mut axum::http::request::Parts, 42 | _state: &S, 43 | ) -> Result { 44 | Self::ip_from_headers(&parts.headers).map(Self) 45 | } 46 | } 47 | }; 48 | } 49 | 50 | define_extractor!( 51 | /// Extracts an IP from `CF-Connecting-IP` (Cloudflare) header 52 | CfConnectingIp, 53 | client_ip::cf_connecting_ip 54 | ); 55 | 56 | define_extractor!( 57 | /// Extracts an IP from `CloudFront-Viewer-Address` (AWS CloudFront) header 58 | CloudFrontViewerAddress, 59 | client_ip::cloudfront_viewer_address 60 | ); 61 | 62 | define_extractor!( 63 | /// Extracts an IP from `Fly-Client-IP` (Fly.io) header 64 | /// 65 | /// When [`FlyClientIp`] extractor is run for health check path, 66 | /// provide required `Fly-Client-IP` header through 67 | /// [`services.http_checks.headers`](https://fly.io/docs/reference/configuration/#services-http_checks) 68 | /// or [`http_service.checks.headers`](https://fly.io/docs/reference/configuration/#services-http_checks) 69 | FlyClientIp, 70 | client_ip::fly_client_ip 71 | ); 72 | 73 | #[cfg(feature = "forwarded-header")] 74 | define_extractor!( 75 | /// Extracts the rightmost IP from `Forwarded` header 76 | RightmostForwarded, 77 | client_ip::rightmost_forwarded 78 | ); 79 | 80 | define_extractor!( 81 | /// Extracts the rightmost IP from `X-Forwarded-For` header 82 | RightmostXForwardedFor, 83 | client_ip::rightmost_x_forwarded_for 84 | ); 85 | 86 | define_extractor!( 87 | /// Extracts an IP from `True-Client-IP` (Akamai, Cloudflare) header 88 | TrueClientIp, 89 | client_ip::true_client_ip 90 | ); 91 | 92 | define_extractor!( 93 | /// Extracts an IP from `X-Real-Ip` (Nginx) header 94 | XRealIp, 95 | client_ip::x_real_ip 96 | ); 97 | 98 | /// Client IP extractor with configurable source 99 | /// 100 | /// The configuration would include knowing the header the last proxy (the 101 | /// one you own or the one your cloud server provides) is using to store 102 | /// user connection IP. Then you'd need to pass a corresponding 103 | /// [`ClientIpSource`] variant into the [`axum::routing::Router::layer`] as 104 | /// an extension. Look at the [example][]. 105 | /// 106 | /// [example]: https://github.com/imbolc/axum-client-ip/blob/main/examples/integration.rs 107 | #[derive(Debug, Clone, Copy)] 108 | pub struct ClientIp(pub IpAddr); 109 | 110 | /// [`ClientIp`] source configuration 111 | #[non_exhaustive] 112 | #[derive(Clone, Debug, Deserialize, Serialize)] 113 | pub enum ClientIpSource { 114 | /// IP from the `CF-Connecting-IP` header 115 | CfConnectingIp, 116 | /// IP from the `CloudFront-Viewer-Address` header 117 | CloudFrontViewerAddress, 118 | /// IP from the [`axum::extract::ConnectInfo`] 119 | ConnectInfo, 120 | /// IP from the `Fly-Client-IP` header 121 | FlyClientIp, 122 | #[cfg(feature = "forwarded-header")] 123 | /// Rightmost IP from the `Forwarded` header 124 | RightmostForwarded, 125 | /// Rightmost IP from the `X-Forwarded-For` header 126 | RightmostXForwardedFor, 127 | /// IP from the `True-Client-IP` header 128 | TrueClientIp, 129 | /// IP from the `X-Real-Ip` header 130 | XRealIp, 131 | } 132 | 133 | impl ClientIpSource { 134 | /// Wraps [`ClientIpSource`] into the [`axum::extract::Extension`] 135 | /// for passing to [`axum::routing::Router::layer`] 136 | pub const fn into_extension(self) -> Extension { 137 | Extension(self) 138 | } 139 | } 140 | 141 | /// Invalid [`ClientIpSource`] 142 | #[derive(Debug)] 143 | pub struct ParseClientIpSourceError(String); 144 | 145 | impl fmt::Display for ParseClientIpSourceError { 146 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 147 | write!(f, "Invalid ClientIpSource value {}", self.0) 148 | } 149 | } 150 | 151 | impl Error for ParseClientIpSourceError {} 152 | 153 | impl FromStr for ClientIpSource { 154 | type Err = ParseClientIpSourceError; 155 | 156 | fn from_str(s: &str) -> Result { 157 | Ok(match s { 158 | "CfConnectingIp" => Self::CfConnectingIp, 159 | "CloudFrontViewerAddress" => Self::CloudFrontViewerAddress, 160 | "ConnectInfo" => Self::ConnectInfo, 161 | "FlyClientIp" => Self::FlyClientIp, 162 | #[cfg(feature = "forwarded-header")] 163 | "RightmostForwarded" => Self::RightmostForwarded, 164 | "RightmostXForwardedFor" => Self::RightmostXForwardedFor, 165 | "TrueClientIp" => Self::TrueClientIp, 166 | "XRealIp" => Self::XRealIp, 167 | _ => return Err(ParseClientIpSourceError(s.to_string())), 168 | }) 169 | } 170 | } 171 | 172 | impl FromRequestParts for ClientIp 173 | where 174 | S: Sync, 175 | { 176 | type Rejection = Rejection; 177 | 178 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 179 | let Some(ip_source) = parts.extensions.get() else { 180 | return Err(Rejection::NoClientIpSource); 181 | }; 182 | 183 | match ip_source { 184 | ClientIpSource::CfConnectingIp => CfConnectingIp::ip_from_headers(&parts.headers), 185 | ClientIpSource::CloudFrontViewerAddress => { 186 | CloudFrontViewerAddress::ip_from_headers(&parts.headers) 187 | } 188 | ClientIpSource::ConnectInfo => parts 189 | .extensions 190 | .get::>() 191 | .map(|ConnectInfo(addr)| addr.ip()) 192 | .ok_or_else(|| Rejection::NoConnectInfo), 193 | ClientIpSource::FlyClientIp => FlyClientIp::ip_from_headers(&parts.headers), 194 | #[cfg(feature = "forwarded-header")] 195 | ClientIpSource::RightmostForwarded => { 196 | RightmostForwarded::ip_from_headers(&parts.headers) 197 | } 198 | ClientIpSource::RightmostXForwardedFor => { 199 | RightmostXForwardedFor::ip_from_headers(&parts.headers) 200 | } 201 | ClientIpSource::TrueClientIp => TrueClientIp::ip_from_headers(&parts.headers), 202 | ClientIpSource::XRealIp => XRealIp::ip_from_headers(&parts.headers), 203 | } 204 | .map(Self) 205 | } 206 | } 207 | 208 | /// Rejection type for IP extractors 209 | #[non_exhaustive] 210 | #[derive(Debug, PartialEq)] 211 | pub enum Rejection { 212 | /// No [`axum::extract::ConnectInfo`] in extensions 213 | NoConnectInfo, 214 | /// No [`ClientIpSource`] in extensions 215 | NoClientIpSource, 216 | /// [`client-ip`] error 217 | ClientIp(client_ip::Error), 218 | } 219 | 220 | impl From for Rejection { 221 | fn from(value: client_ip::Error) -> Self { 222 | Self::ClientIp(value) 223 | } 224 | } 225 | 226 | impl fmt::Display for Rejection { 227 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 228 | match self { 229 | Rejection::NoConnectInfo => { 230 | write!(f, "Add `axum::extract::ConnectInfo` to request extensions") 231 | } 232 | Rejection::NoClientIpSource => write!( 233 | f, 234 | "Add `axum_client_ip::ClientIpSource` to request extensions" 235 | ), 236 | Rejection::ClientIp(e) => write!(f, "{e}"), 237 | } 238 | } 239 | } 240 | 241 | impl std::error::Error for Rejection {} 242 | 243 | impl IntoResponse for Rejection { 244 | fn into_response(self) -> Response { 245 | let title = match self { 246 | Self::NoConnectInfo | Self::NoClientIpSource => "500 Axum Misconfiguration", 247 | Self::ClientIp { .. } => "500 Proxy Server Misconfiguration", 248 | }; 249 | let footer = "(the request is rejected by axum-client-ip)"; 250 | let text = format!("{title}\n\n{self}\n\n{footer}"); 251 | (StatusCode::INTERNAL_SERVER_ERROR, text).into_response() 252 | } 253 | } 254 | 255 | #[cfg(test)] 256 | mod tests { 257 | use axum::{ 258 | Router, 259 | body::Body, 260 | http::{Request, StatusCode}, 261 | routing::get, 262 | }; 263 | use http_body_util::BodyExt; 264 | use tower::ServiceExt; 265 | 266 | #[cfg(feature = "forwarded-header")] 267 | use super::RightmostForwarded; 268 | use super::{CfConnectingIp, FlyClientIp, RightmostXForwardedFor, TrueClientIp, XRealIp}; 269 | use crate::CloudFrontViewerAddress; 270 | 271 | const VALID_IPV4: &str = "1.2.3.4"; 272 | const VALID_IPV6: &str = "1:23:4567:89ab:c:d:e:f"; 273 | 274 | async fn body_to_string(body: Body) -> String { 275 | let bytes = body.collect().await.unwrap().to_bytes(); 276 | String::from_utf8_lossy(&bytes).into() 277 | } 278 | 279 | #[tokio::test] 280 | async fn cf_connecting_ip() { 281 | let header = "cf-connecting-ip"; 282 | 283 | fn app() -> Router { 284 | Router::new().route( 285 | "/", 286 | get(|ip: CfConnectingIp| async move { ip.0.to_string() }), 287 | ) 288 | } 289 | 290 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 291 | let resp = app().oneshot(req).await.unwrap(); 292 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 293 | 294 | let req = Request::builder() 295 | .uri("/") 296 | .header(header, VALID_IPV4) 297 | .body(Body::empty()) 298 | .unwrap(); 299 | let resp = app().oneshot(req).await.unwrap(); 300 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 301 | 302 | let req = Request::builder() 303 | .uri("/") 304 | .header(header, VALID_IPV6) 305 | .body(Body::empty()) 306 | .unwrap(); 307 | let resp = app().oneshot(req).await.unwrap(); 308 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 309 | } 310 | 311 | #[tokio::test] 312 | async fn cloudfront_viewer_address() { 313 | let header = "cloudfront-viewer-address"; 314 | 315 | let valid_header_value_v4 = format!("{VALID_IPV4}:8000"); 316 | let valid_header_value_v6 = format!("{VALID_IPV6}:8000"); 317 | 318 | fn app() -> Router { 319 | Router::new().route( 320 | "/", 321 | get(|ip: CloudFrontViewerAddress| async move { ip.0.to_string() }), 322 | ) 323 | } 324 | 325 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 326 | let resp = app().oneshot(req).await.unwrap(); 327 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 328 | 329 | let req = Request::builder() 330 | .uri("/") 331 | .header(header, &valid_header_value_v4) 332 | .body(Body::empty()) 333 | .unwrap(); 334 | let resp = app().oneshot(req).await.unwrap(); 335 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 336 | 337 | let req = Request::builder() 338 | .uri("/") 339 | .header(header, &valid_header_value_v6) 340 | .body(Body::empty()) 341 | .unwrap(); 342 | let resp = app().oneshot(req).await.unwrap(); 343 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 344 | } 345 | 346 | #[tokio::test] 347 | async fn fly_client_ip() { 348 | let header = "fly-client-ip"; 349 | 350 | fn app() -> Router { 351 | Router::new().route("/", get(|ip: FlyClientIp| async move { ip.0.to_string() })) 352 | } 353 | 354 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 355 | let resp = app().oneshot(req).await.unwrap(); 356 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 357 | 358 | let req = Request::builder() 359 | .uri("/") 360 | .header(header, VALID_IPV4) 361 | .body(Body::empty()) 362 | .unwrap(); 363 | let resp = app().oneshot(req).await.unwrap(); 364 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 365 | 366 | let req = Request::builder() 367 | .uri("/") 368 | .header(header, VALID_IPV6) 369 | .body(Body::empty()) 370 | .unwrap(); 371 | let resp = app().oneshot(req).await.unwrap(); 372 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 373 | } 374 | 375 | #[cfg(feature = "forwarded-header")] 376 | #[tokio::test] 377 | async fn rightmost_forwarded() { 378 | let header = "forwarded"; 379 | 380 | fn app() -> Router { 381 | Router::new().route( 382 | "/", 383 | get(|ip: RightmostForwarded| async move { ip.0.to_string() }), 384 | ) 385 | } 386 | 387 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 388 | let resp = app().oneshot(req).await.unwrap(); 389 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 390 | 391 | let req = Request::builder() 392 | .uri("/") 393 | .header(header, format!("for=[{VALID_IPV6}]:8000")) 394 | .body(Body::empty()) 395 | .unwrap(); 396 | let resp = app().oneshot(req).await.unwrap(); 397 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 398 | 399 | let req = Request::builder() 400 | .uri("/") 401 | .header("Forwarded", r#"for="_mdn""#) 402 | .header("Forwarded", r#"For="[2001:db8:cafe::17]:4711""#) 403 | .header("Forwarded", r#"for=192.0.2.60;proto=http;by=203.0.113.43"#) 404 | .body(Body::empty()) 405 | .unwrap(); 406 | let resp = app().oneshot(req).await.unwrap(); 407 | assert_eq!(body_to_string(resp.into_body()).await, "192.0.2.60"); 408 | } 409 | 410 | #[tokio::test] 411 | async fn rightmost_x_forwarded_for() { 412 | fn app() -> Router { 413 | Router::new().route( 414 | "/", 415 | get(|ip: RightmostXForwardedFor| async move { ip.0.to_string() }), 416 | ) 417 | } 418 | 419 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 420 | let resp = app().oneshot(req).await.unwrap(); 421 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 422 | 423 | let req = Request::builder() 424 | .uri("/") 425 | .header( 426 | "X-Forwarded-For", 427 | "1.1.1.1, foo, 2001:db8:85a3:8d3:1319:8a2e:370:7348", 428 | ) 429 | .header("X-Forwarded-For", "bar") 430 | .header("X-Forwarded-For", format!("2.2.2.2, {VALID_IPV4}")) 431 | .body(Body::empty()) 432 | .unwrap(); 433 | let resp = app().oneshot(req).await.unwrap(); 434 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 435 | } 436 | 437 | #[tokio::test] 438 | async fn true_client_ip() { 439 | let header = "true-client-ip"; 440 | 441 | fn app() -> Router { 442 | Router::new().route("/", get(|ip: TrueClientIp| async move { ip.0.to_string() })) 443 | } 444 | 445 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 446 | let resp = app().oneshot(req).await.unwrap(); 447 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 448 | 449 | let req = Request::builder() 450 | .uri("/") 451 | .header(header, VALID_IPV4) 452 | .body(Body::empty()) 453 | .unwrap(); 454 | let resp = app().oneshot(req).await.unwrap(); 455 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 456 | 457 | let req = Request::builder() 458 | .uri("/") 459 | .header(header, VALID_IPV6) 460 | .body(Body::empty()) 461 | .unwrap(); 462 | let resp = app().oneshot(req).await.unwrap(); 463 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 464 | } 465 | 466 | #[tokio::test] 467 | async fn x_real_ip() { 468 | let header = "x-real-ip"; 469 | 470 | fn app() -> Router { 471 | Router::new().route("/", get(|ip: XRealIp| async move { ip.0.to_string() })) 472 | } 473 | 474 | let req = Request::builder().uri("/").body(Body::empty()).unwrap(); 475 | let resp = app().oneshot(req).await.unwrap(); 476 | assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); 477 | 478 | let req = Request::builder() 479 | .uri("/") 480 | .header(header, VALID_IPV4) 481 | .body(Body::empty()) 482 | .unwrap(); 483 | let resp = app().oneshot(req).await.unwrap(); 484 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV4); 485 | 486 | let req = Request::builder() 487 | .uri("/") 488 | .header(header, VALID_IPV6) 489 | .body(Body::empty()) 490 | .unwrap(); 491 | let resp = app().oneshot(req).await.unwrap(); 492 | assert_eq!(body_to_string(resp.into_body()).await, VALID_IPV6); 493 | } 494 | } 495 | --------------------------------------------------------------------------------