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/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/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/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 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"), }