From 604bec16c8432ab362603cad1c06f65b0c3f9491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Wed, 1 Oct 2025 19:11:42 -0300 Subject: [PATCH 1/7] Add a config to set a trusted header to extract the IP from --- Cargo.lock | 27 ---------------- Cargo.toml | 1 - crates/common/src/config/signer.rs | 8 +++++ crates/signer/Cargo.toml | 1 - crates/signer/src/service.rs | 51 ++++++++++++------------------ tests/src/utils.rs | 2 ++ 6 files changed, 31 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 285bbd94..e06d7ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1679,7 +1679,6 @@ dependencies = [ "blsful", "cb-common", "cb-metrics", - "client-ip", "eyre", "futures", "headers", @@ -1838,16 +1837,6 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" -[[package]] -name = "client-ip" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31211fc26899744f5b22521fdc971e5f3875991d8880537537470685a0e9552d" -dependencies = [ - "forwarded-header-value", - "http", -] - [[package]] name = "cmake" version = "0.1.54" @@ -2840,16 +2829,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "forwarded-header-value" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" -dependencies = [ - "nonempty", - "thiserror 1.0.69", -] - [[package]] name = "fs-err" version = "3.1.0" @@ -3970,12 +3949,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nonempty" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" - [[package]] name = "nu-ansi-term" version = "0.50.1" diff --git a/Cargo.toml b/Cargo.toml index b0533144..6ea8ba96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,6 @@ cb-pbs = { path = "crates/pbs" } cb-signer = { path = "crates/signer" } cipher = "0.4" clap = { version = "4.5.4", features = ["derive", "env"] } -client-ip = { version = "0.1.1", features = [ "forwarded-header" ] } color-eyre = "0.6.3" const_format = "0.2.34" ctr = "0.9.2" diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index b4c5db16..e3caaa3f 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -99,6 +99,9 @@ pub struct SignerConfig { #[serde(default = "default_tls_mode")] pub tls_mode: TlsMode, + /// Optional name of the HTTP header to use to extract the real client IP + pub trusted_ip_header: Option, + /// Inner type-specific configuration #[serde(flatten)] pub inner: SignerType, @@ -194,6 +197,7 @@ pub struct StartSignerConfig { pub jwt_auth_fail_timeout_seconds: u32, pub dirk: Option, pub tls_certificates: Option<(Vec, Vec)>, + pub trusted_ip_header: Option, } impl StartSignerConfig { @@ -247,6 +251,8 @@ impl StartSignerConfig { } }; + let trusted_ip_header = signer_config.trusted_ip_header; + match signer_config.inner { SignerType::Local { loader, store, .. } => Ok(StartSignerConfig { chain: config.chain, @@ -259,6 +265,7 @@ impl StartSignerConfig { store, dirk: None, tls_certificates, + trusted_ip_header, }), SignerType::Dirk { @@ -305,6 +312,7 @@ impl StartSignerConfig { }, }), tls_certificates, + trusted_ip_header, }) } diff --git a/crates/signer/Cargo.toml b/crates/signer/Cargo.toml index 1a688e1b..7c6e63fa 100644 --- a/crates/signer/Cargo.toml +++ b/crates/signer/Cargo.toml @@ -14,7 +14,6 @@ bimap.workspace = true blsful.workspace = true cb-common.workspace = true cb-metrics.workspace = true -client-ip.workspace = true eyre.workspace = true futures.workspace = true headers.workspace = true diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index e9480db1..ae3144c2 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -36,7 +36,6 @@ use cb_common::{ utils::{decode_jwt, validate_admin_jwt, validate_jwt}, }; use cb_metrics::provider::MetricsProvider; -use client_ip::*; use eyre::Context; use headers::{Authorization, authorization::Bearer}; use parking_lot::RwLock as ParkingRwLock; @@ -83,6 +82,9 @@ struct SigningState { // JWT auth failure settings jwt_auth_fail_limit: u32, jwt_auth_fail_timeout: Duration, + + /// Header to extract the trusted client IP from + trusted_ip_header: Option, } impl SigningService { @@ -102,6 +104,7 @@ impl SigningService { jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())), jwt_auth_fail_limit: config.jwt_auth_fail_limit, jwt_auth_fail_timeout: Duration::from_secs(config.jwt_auth_fail_timeout_seconds as u64), + trusted_ip_header: config.trusted_ip_header, }; // Get the signer counts @@ -122,6 +125,7 @@ impl SigningService { loaded_proxies, jwt_auth_fail_limit =? state.jwt_auth_fail_limit, jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout, + trusted_ip_header = state.trusted_ip_header, "Starting signing service" ); @@ -226,34 +230,21 @@ fn mark_jwt_failure(state: &SigningState, client_ip: IpAddr) { /// Get the true client IP from the request headers or fallback to the socket /// address -fn get_true_ip(req_headers: &HeaderMap, addr: &SocketAddr) -> eyre::Result { - let ip_extractors = [ - cf_connecting_ip, - cloudfront_viewer_address, - fly_client_ip, - rightmost_forwarded, - rightmost_x_forwarded_for, - true_client_ip, - x_real_ip, - ]; - - // Run each extractor in order and return the first valid IP found - for extractor in ip_extractors { - match extractor(req_headers) { - Ok(true_ip) => { - return Ok(true_ip); - } - Err(e) => { - match e { - Error::AbsentHeader { .. } => continue, // Missing headers are fine - _ => return Err(eyre::eyre!(e.to_string())), // Report anything else - } - } - } +fn get_true_ip( + req_headers: &HeaderMap, + addr: &SocketAddr, + trusted_ip_header: &Option, +) -> eyre::Result { + if let Some(header) = trusted_ip_header { + req_headers + .get(header) + .ok_or(eyre::eyre!("{header} header not found"))? + .to_str()? + .parse() + .map_err(|_| eyre::eyre!("Trustrd IP header has not a valid IP")) + } else { + Ok(addr.ip()) } - - // Fallback to the socket IP - Ok(addr.ip()) } /// Authentication middleware layer @@ -266,7 +257,7 @@ async fn jwt_auth( next: Next, ) -> Result { // Check if the request needs to be rate limited - let client_ip = get_true_ip(&req_headers, &addr).map_err(|e| { + let client_ip = get_true_ip(&req_headers, &addr, &state.trusted_ip_header).map_err(|e| { error!("Failed to get client IP: {e}"); SignerModuleError::RequestError("failed to get client IP".to_string()) })?; @@ -372,7 +363,7 @@ async fn admin_auth( next: Next, ) -> Result { // Check if the request needs to be rate limited - let client_ip = get_true_ip(&req_headers, &addr).map_err(|e| { + let client_ip = get_true_ip(&req_headers, &addr, &state.trusted_ip_header).map_err(|e| { error!("Failed to get client IP: {e}"); SignerModuleError::RequestError("failed to get client IP".to_string()) })?; diff --git a/tests/src/utils.rs b/tests/src/utils.rs index c66bfed6..63388353 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -129,6 +129,7 @@ pub fn get_signer_config(loader: SignerLoader, tls: bool) -> SignerConfig { jwt_auth_fail_timeout_seconds: SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, inner: SignerType::Local { loader, store: None }, tls_mode: if tls { TlsMode::Certificate(PathBuf::new()) } else { TlsMode::Insecure }, + trusted_ip_header: None, } } @@ -164,6 +165,7 @@ pub fn get_start_signer_config( jwt_auth_fail_timeout_seconds: signer_config.jwt_auth_fail_timeout_seconds, dirk: None, tls_certificates, + trusted_ip_header: None, }, _ => panic!("Only local signers are supported in tests"), } From df41968410ea78de968ff5d820d0f9b345bb7afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Thu, 2 Oct 2025 10:55:27 -0300 Subject: [PATCH 2/7] Add docs --- config.example.toml | 3 +++ docs/docs/get_started/configuration.md | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/config.example.toml b/config.example.toml index 5b69f108..b59f5dae 100644 --- a/config.example.toml +++ b/config.example.toml @@ -169,6 +169,9 @@ jwt_auth_fail_limit = 3 # This also defines the interval at which failed attempts are regularly checked and expired ones are cleaned up. # OPTIONAL, DEFAULT: 300 jwt_auth_fail_timeout_seconds = 300 +# HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx) +# OPTIONAL. If missing, the client IP will be taken directly from the TCP connection. +# trusted_ip_header = "X-Real-IP" # [signer.tls_mode] # How to use TLS for the Signer's HTTP server; two modes are supported: diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index acff09e7..a5b68890 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -388,6 +388,23 @@ path = "path/to/your/cert/folder" Where `path` is the aforementioned folder. It defaults to `./certs` but can be replaced with whichever directory your certificate and private key file reside in, as long as they're readable by the Signer service (or its Docker container, if using Docker). +### Rate limit + +The Signer service implements a rate limit system of 3 failed authentications every 5 minutes. These values can be modified in the config file: + +```toml +[signer] +... +jwt_auth_fail_limit = 3 # The amount of failed requests allowed +jwt_auth_fail_timeout_seconds = 300 # The time window in seconds +``` + +The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead: + +```toml +[signer] +trusted_ip_header = "X-Real-IP" +``` ## Custom module From 51a1699ed20b6a5b892b77a4674a13f2d5524df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Tue, 21 Oct 2025 11:38:13 -0300 Subject: [PATCH 3/7] Give option for unique header or comma-separated rightmost value --- crates/common/src/commit/client.rs | 9 +--- crates/common/src/config/signer.rs | 23 +++++++--- crates/signer/src/lib.rs | 1 + crates/signer/src/service.rs | 32 +++----------- crates/signer/src/utils.rs | 69 ++++++++++++++++++++++++++++++ tests/src/utils.rs | 11 ++--- 6 files changed, 102 insertions(+), 43 deletions(-) create mode 100644 crates/signer/src/utils.rs diff --git a/crates/common/src/commit/client.rs b/crates/common/src/commit/client.rs index b614ab0f..98d8c26d 100644 --- a/crates/common/src/commit/client.rs +++ b/crates/common/src/commit/client.rs @@ -2,10 +2,7 @@ use std::path::PathBuf; use alloy::primitives::Address; use eyre::WrapErr; -use reqwest::{ - Certificate, - header::{AUTHORIZATION, HeaderMap, HeaderValue}, -}; +use reqwest::Certificate; use serde::{Deserialize, Serialize}; use url::Url; @@ -26,9 +23,7 @@ use crate::{ }, response::{BlsSignResponse, EcdsaSignResponse}, }, - constants::SIGNER_JWT_EXPIRATION, - signer::EcdsaSignature, - types::{BlsPublicKey, BlsSignature, Jwt, ModuleId}, + types::{BlsPublicKey, Jwt, ModuleId}, utils::create_jwt, }; diff --git a/crates/common/src/config/signer.rs b/crates/common/src/config/signer.rs index 7e1082ff..98608413 100644 --- a/crates/common/src/config/signer.rs +++ b/crates/common/src/config/signer.rs @@ -69,6 +69,16 @@ pub enum TlsMode { Certificate(PathBuf), } +/// Reverse proxy setup, used to extract real client's IP +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "snake_case")] +pub enum ReverseProxyHeaderSetup { + #[default] + None, + Unique(String), + Rightmost(String), +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "snake_case")] pub struct SignerConfig { @@ -99,8 +109,9 @@ pub struct SignerConfig { #[serde(default = "default_tls_mode")] pub tls_mode: TlsMode, - /// Optional name of the HTTP header to use to extract the real client IP - pub trusted_ip_header: Option, + /// Reverse proxy setup to extract real client's IP + #[serde(default)] + pub reverse_proxy: ReverseProxyHeaderSetup, /// Inner type-specific configuration #[serde(flatten)] @@ -197,7 +208,7 @@ pub struct StartSignerConfig { pub jwt_auth_fail_timeout_seconds: u32, pub dirk: Option, pub tls_certificates: Option<(Vec, Vec)>, - pub trusted_ip_header: Option, + pub reverse_proxy: ReverseProxyHeaderSetup, } impl StartSignerConfig { @@ -251,7 +262,7 @@ impl StartSignerConfig { } }; - let trusted_ip_header = signer_config.trusted_ip_header; + let reverse_proxy = signer_config.reverse_proxy; match signer_config.inner { SignerType::Local { loader, store, .. } => Ok(StartSignerConfig { @@ -265,7 +276,7 @@ impl StartSignerConfig { store, dirk: None, tls_certificates, - trusted_ip_header, + reverse_proxy, }), SignerType::Dirk { @@ -312,7 +323,7 @@ impl StartSignerConfig { }, }), tls_certificates, - trusted_ip_header, + reverse_proxy, }) } diff --git a/crates/signer/src/lib.rs b/crates/signer/src/lib.rs index 4b5e1451..b4b9ecc4 100644 --- a/crates/signer/src/lib.rs +++ b/crates/signer/src/lib.rs @@ -4,3 +4,4 @@ pub mod manager; mod metrics; mod proto; pub mod service; +mod utils; diff --git a/crates/signer/src/service.rs b/crates/signer/src/service.rs index 2a06ce13..219a4ae5 100644 --- a/crates/signer/src/service.rs +++ b/crates/signer/src/service.rs @@ -30,7 +30,7 @@ use cb_common::{ }, response::{BlsSignResponse, EcdsaSignResponse}, }, - config::{ModuleSigningConfig, StartSignerConfig}, + config::{ModuleSigningConfig, ReverseProxyHeaderSetup, StartSignerConfig}, constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION}, types::{BlsPublicKey, Chain, Jwt, ModuleId, SignatureRequestInfo}, utils::{decode_jwt, validate_admin_jwt, validate_jwt}, @@ -48,6 +48,7 @@ use crate::{ error::SignerModuleError, manager::{SigningManager, dirk::DirkManager, local::LocalSigningManager}, metrics::{SIGNER_METRICS_REGISTRY, SIGNER_STATUS, uri_to_tag}, + utils::get_true_ip, }; pub const REQUEST_MAX_BODY_LENGTH: usize = 1024 * 1024; // 1 MB @@ -84,7 +85,7 @@ struct SigningState { jwt_auth_fail_timeout: Duration, /// Header to extract the trusted client IP from - trusted_ip_header: Option, + reverse_proxy: ReverseProxyHeaderSetup, } impl SigningService { @@ -104,7 +105,7 @@ impl SigningService { jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())), jwt_auth_fail_limit: config.jwt_auth_fail_limit, jwt_auth_fail_timeout: Duration::from_secs(config.jwt_auth_fail_timeout_seconds as u64), - trusted_ip_header: config.trusted_ip_header, + reverse_proxy: config.reverse_proxy, }; // Get the signer counts @@ -125,7 +126,7 @@ impl SigningService { loaded_proxies, jwt_auth_fail_limit =? state.jwt_auth_fail_limit, jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout, - trusted_ip_header = state.trusted_ip_header, + reverse_proxy =? state.reverse_proxy, "Starting signing service" ); @@ -228,25 +229,6 @@ fn mark_jwt_failure(state: &SigningState, client_ip: IpAddr) { failure_info.last_failure = Instant::now(); } -/// Get the true client IP from the request headers or fallback to the socket -/// address -fn get_true_ip( - req_headers: &HeaderMap, - addr: &SocketAddr, - trusted_ip_header: &Option, -) -> eyre::Result { - if let Some(header) = trusted_ip_header { - req_headers - .get(header) - .ok_or(eyre::eyre!("{header} header not found"))? - .to_str()? - .parse() - .map_err(|_| eyre::eyre!("Trustrd IP header has not a valid IP")) - } else { - Ok(addr.ip()) - } -} - /// Authentication middleware layer async fn jwt_auth( State(state): State, @@ -257,7 +239,7 @@ async fn jwt_auth( next: Next, ) -> Result { // Check if the request needs to be rate limited - let client_ip = get_true_ip(&req_headers, &addr, &state.trusted_ip_header).map_err(|e| { + let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| { error!("Failed to get client IP: {e}"); SignerModuleError::RequestError("failed to get client IP".to_string()) })?; @@ -367,7 +349,7 @@ async fn admin_auth( next: Next, ) -> Result { // Check if the request needs to be rate limited - let client_ip = get_true_ip(&req_headers, &addr, &state.trusted_ip_header).map_err(|e| { + let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| { error!("Failed to get client IP: {e}"); SignerModuleError::RequestError("failed to get client IP".to_string()) })?; diff --git a/crates/signer/src/utils.rs b/crates/signer/src/utils.rs new file mode 100644 index 00000000..a6216f5b --- /dev/null +++ b/crates/signer/src/utils.rs @@ -0,0 +1,69 @@ +use std::net::{IpAddr, SocketAddr}; + +use axum::http::HeaderMap; +use cb_common::config::ReverseProxyHeaderSetup; +use tracing::info; + +#[derive(Debug, thiserror::Error)] +pub enum IpError { + #[error("header `{0}` is not present")] + NotPresent(String), + #[error("header value has invalid characters")] + HasInvalidCharacters, + #[error("header value is not a valid IP address")] + InvalidValue, + #[error("header `{0}` appears multiple times but expected to be unique")] + NotUnique(String), +} + +/// Get the true client IP from the request headers or fallback to the socket +/// address +pub fn get_true_ip( + headers: &HeaderMap, + addr: &SocketAddr, + reverse_proxy: &ReverseProxyHeaderSetup, +) -> Result { + let ip = match reverse_proxy { + ReverseProxyHeaderSetup::None => Ok(addr.ip()), + ReverseProxyHeaderSetup::Unique(header) => get_ip_from_unique_header(headers, header), + ReverseProxyHeaderSetup::Rightmost(header) => get_ip_from_rightmost_value(headers, header), + }; + + info!("IP: {ip:?}"); + + ip +} + +fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result { + let mut values = headers.get_all(header_name).iter(); + + let first_value = values.next().ok_or(IpError::NotPresent(header_name.to_string()))?; + let ip = first_value + .to_str() + .map_err(|_| IpError::HasInvalidCharacters)? + .parse::() + .map_err(|_| IpError::InvalidValue)?; + + if values.next().is_some() { + return Err(IpError::NotUnique(header_name.to_string())); + } + + Ok(ip) +} + +fn get_ip_from_rightmost_value(headers: &HeaderMap, header_name: &str) -> Result { + let last_value = headers + .get_all(header_name) + .iter() + .last() + .ok_or(IpError::NotPresent(header_name.to_string()))? + .to_str() + .map_err(|_| IpError::HasInvalidCharacters)?; + + last_value + .rsplit_once(",") + .map(|(_, rightmost)| rightmost) + .unwrap_or(last_value) + .parse::() + .map_err(|_| IpError::InvalidValue) +} diff --git a/tests/src/utils.rs b/tests/src/utils.rs index 8474f58e..330f1e64 100644 --- a/tests/src/utils.rs +++ b/tests/src/utils.rs @@ -9,9 +9,10 @@ use alloy::primitives::{B256, U256}; use cb_common::{ config::{ CommitBoostConfig, LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig, - PbsModuleConfig, RelayConfig, SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, - SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig, - SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode, + PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, SIGNER_IMAGE_DEFAULT, + SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, + SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig, + StaticPbsConfig, TlsMode, }, pbs::{RelayClient, RelayEntry}, signer::SignerLoader, @@ -130,7 +131,7 @@ pub fn get_signer_config(loader: SignerLoader, tls: bool) -> SignerConfig { jwt_auth_fail_timeout_seconds: SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, inner: SignerType::Local { loader, store: None }, tls_mode: if tls { TlsMode::Certificate(PathBuf::new()) } else { TlsMode::Insecure }, - trusted_ip_header: None, + reverse_proxy: ReverseProxyHeaderSetup::None, } } @@ -166,7 +167,7 @@ pub fn get_start_signer_config( jwt_auth_fail_timeout_seconds: signer_config.jwt_auth_fail_timeout_seconds, dirk: None, tls_certificates, - trusted_ip_header: None, + reverse_proxy: ReverseProxyHeaderSetup::None, }, _ => panic!("Only local signers are supported in tests"), } From 40ddea5e74d74b571e8f7be88074bba67d680e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Tue, 21 Oct 2025 11:47:15 -0300 Subject: [PATCH 4/7] Update docs --- config.example.toml | 7 ++++++- docs/docs/get_started/configuration.md | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/config.example.toml b/config.example.toml index 6eee6121..9502906f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -169,9 +169,14 @@ jwt_auth_fail_limit = 3 # This also defines the interval at which failed attempts are regularly checked and expired ones are cleaned up. # OPTIONAL, DEFAULT: 300 jwt_auth_fail_timeout_seconds = 300 + # HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx) # OPTIONAL. If missing, the client IP will be taken directly from the TCP connection. -# trusted_ip_header = "X-Real-IP" +# [signer.reverse_proxy] +# Unique: HTTP header name to use to determine the real client IP. Expected to appear only once in the request. +# unique = "X-Real-IP" +# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. Rightmost IP is the client IP. +# rightmost = "X-Forwarded-For" # [signer.tls_mode] # How to use TLS for the Signer's HTTP server; two modes are supported: diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index a5b68890..fe815cf7 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -399,11 +399,21 @@ jwt_auth_fail_limit = 3 # The amount of failed requests allowed jwt_auth_fail_timeout_seconds = 300 # The time window in seconds ``` -The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead: +The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: + +- `unique`: The name of the HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. +- `rightmost`: The name of the HTTP header that contains a comma-separated list of IPs. The rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. + +Examples: ```toml -[signer] -trusted_ip_header = "X-Real-IP" +[signer.reverse_proxy] +unique = "X-Real-IP" +``` + +```toml +[signer.reverse_proxy] +rightmost = "X-Forwarded-For" ``` ## Custom module From 443e95b4204e12bc007a8ff5986c0c814a3092a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Tue, 21 Oct 2025 11:49:07 -0300 Subject: [PATCH 5/7] Clean up --- crates/signer/src/utils.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/signer/src/utils.rs b/crates/signer/src/utils.rs index a6216f5b..6a8ea19f 100644 --- a/crates/signer/src/utils.rs +++ b/crates/signer/src/utils.rs @@ -2,7 +2,6 @@ use std::net::{IpAddr, SocketAddr}; use axum::http::HeaderMap; use cb_common::config::ReverseProxyHeaderSetup; -use tracing::info; #[derive(Debug, thiserror::Error)] pub enum IpError { @@ -23,15 +22,11 @@ pub fn get_true_ip( addr: &SocketAddr, reverse_proxy: &ReverseProxyHeaderSetup, ) -> Result { - let ip = match reverse_proxy { + match reverse_proxy { ReverseProxyHeaderSetup::None => Ok(addr.ip()), ReverseProxyHeaderSetup::Unique(header) => get_ip_from_unique_header(headers, header), ReverseProxyHeaderSetup::Rightmost(header) => get_ip_from_rightmost_value(headers, header), - }; - - info!("IP: {ip:?}"); - - ip + } } fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result { From 54c692efea284fbaede6cb6c77d38a294e92cb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Tue, 21 Oct 2025 12:11:19 -0300 Subject: [PATCH 6/7] Clippy --- crates/signer/src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/signer/src/utils.rs b/crates/signer/src/utils.rs index 6a8ea19f..a6d313ef 100644 --- a/crates/signer/src/utils.rs +++ b/crates/signer/src/utils.rs @@ -50,7 +50,7 @@ fn get_ip_from_rightmost_value(headers: &HeaderMap, header_name: &str) -> Result let last_value = headers .get_all(header_name) .iter() - .last() + .next_back() .ok_or(IpError::NotPresent(header_name.to_string()))? .to_str() .map_err(|_| IpError::HasInvalidCharacters)?; From 4ce547aefb140958d4159e8b609500495edbd7bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20I=C3=B1aki=20Bilbao?= Date: Tue, 21 Oct 2025 17:05:48 -0300 Subject: [PATCH 7/7] Apply suggestions from code review Co-authored-by: Joe Clapis --- config.example.toml | 4 ++-- docs/docs/get_started/configuration.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9502906f..94824984 100644 --- a/config.example.toml +++ b/config.example.toml @@ -173,9 +173,9 @@ jwt_auth_fail_timeout_seconds = 300 # HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx) # OPTIONAL. If missing, the client IP will be taken directly from the TCP connection. # [signer.reverse_proxy] -# Unique: HTTP header name to use to determine the real client IP. Expected to appear only once in the request. +# Unique: HTTP header name to use to determine the real client IP. Expected to appear only once in the request. Requests with multiple values of this header will be rejected. # unique = "X-Real-IP" -# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. Rightmost IP is the client IP. +# Rightmost: HTTP header name to use to determine the real client IP from a comma-separated list of IPs. Rightmost IP is the client IP. If the header appears multiple times, the last value will be used. # rightmost = "X-Forwarded-For" # [signer.tls_mode] diff --git a/docs/docs/get_started/configuration.md b/docs/docs/get_started/configuration.md index fe815cf7..abdb9e20 100644 --- a/docs/docs/get_started/configuration.md +++ b/docs/docs/get_started/configuration.md @@ -401,7 +401,7 @@ jwt_auth_fail_timeout_seconds = 300 # The time window in seconds The rate limit is applied to the IP address of the client making the request. By default, the IP is extracted directly from the TCP connection. If you're running the Signer service behind a reverse proxy (e.g. Nginx), you can configure it to extract the IP from a custom HTTP header instead. There're two options: -- `unique`: The name of the HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. +- `unique`: The name of the HTTP header that contains the IP. This header is expected to appear only once in the request. This is common when using `X-Real-IP`, `True-Client-IP`, etc. If a request is received that has multiple values for this header, it will be considered invalid and rejected. - `rightmost`: The name of the HTTP header that contains a comma-separated list of IPs. The rightmost IP in the list is used. If the header appears multiple times, the last occurrence is used. This is common when using `X-Forwarded-For`. Examples: