Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 2 additions & 7 deletions crates/common/src/commit/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
};

Expand Down
23 changes: 17 additions & 6 deletions crates/common/src/config/signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<String>,
/// Reverse proxy setup to extract real client's IP
#[serde(default)]
pub reverse_proxy: ReverseProxyHeaderSetup,

/// Inner type-specific configuration
#[serde(flatten)]
Expand Down Expand Up @@ -197,7 +208,7 @@ pub struct StartSignerConfig {
pub jwt_auth_fail_timeout_seconds: u32,
pub dirk: Option<DirkConfig>,
pub tls_certificates: Option<(Vec<u8>, Vec<u8>)>,
pub trusted_ip_header: Option<String>,
pub reverse_proxy: ReverseProxyHeaderSetup,
}

impl StartSignerConfig {
Expand Down Expand Up @@ -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 {
Expand All @@ -265,7 +276,7 @@ impl StartSignerConfig {
store,
dirk: None,
tls_certificates,
trusted_ip_header,
reverse_proxy,
}),

SignerType::Dirk {
Expand Down Expand Up @@ -312,7 +323,7 @@ impl StartSignerConfig {
},
}),
tls_certificates,
trusted_ip_header,
reverse_proxy,
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/signer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ pub mod manager;
mod metrics;
mod proto;
pub mod service;
mod utils;
32 changes: 7 additions & 25 deletions crates/signer/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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
Expand Down Expand Up @@ -84,7 +85,7 @@ struct SigningState {
jwt_auth_fail_timeout: Duration,

/// Header to extract the trusted client IP from
trusted_ip_header: Option<String>,
reverse_proxy: ReverseProxyHeaderSetup,
}

impl SigningService {
Expand All @@ -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
Expand All @@ -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"
);

Expand Down Expand Up @@ -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<String>,
) -> eyre::Result<IpAddr> {
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<SigningState>,
Expand All @@ -257,7 +239,7 @@ async fn jwt_auth(
next: Next,
) -> Result<Response, SignerModuleError> {
// 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())
})?;
Expand Down Expand Up @@ -367,7 +349,7 @@ async fn admin_auth(
next: Next,
) -> Result<Response, SignerModuleError> {
// 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())
})?;
Expand Down
64 changes: 64 additions & 0 deletions crates/signer/src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use std::net::{IpAddr, SocketAddr};

use axum::http::HeaderMap;
use cb_common::config::ReverseProxyHeaderSetup;

#[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<IpAddr, IpError> {
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),
}
}

fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result<IpAddr, IpError> {
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::<IpAddr>()
.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<IpAddr, IpError> {
let last_value = headers
.get_all(header_name)
.iter()
.next_back()
.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::<IpAddr>()
.map_err(|_| IpError::InvalidValue)
}
16 changes: 13 additions & 3 deletions docs/docs/get_started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions tests/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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"),
}
Expand Down
Loading