Skip to content

Commit e58d67e

Browse files
refactor(signer): Use config-based header to extract IP from (#388)
Co-authored-by: Joe Clapis <[email protected]>
1 parent 58b4eb9 commit e58d67e

File tree

10 files changed

+134
-68
lines changed

10 files changed

+134
-68
lines changed

Cargo.lock

Lines changed: 0 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ cb-pbs = { path = "crates/pbs" }
3535
cb-signer = { path = "crates/signer" }
3636
cipher = "0.4"
3737
clap = { version = "4.5.4", features = ["derive", "env"] }
38-
client-ip = { version = "0.1.1", features = [ "forwarded-header" ] }
3938
color-eyre = "0.6.3"
4039
const_format = "0.2.34"
4140
ctr = "0.9.2"

config.example.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ jwt_auth_fail_limit = 3
185185
# OPTIONAL, DEFAULT: 300
186186
jwt_auth_fail_timeout_seconds = 300
187187

188+
# HTTP header to use to determine the real client IP, if the Signer is behind a proxy (e.g. nginx)
189+
# OPTIONAL. If missing, the client IP will be taken directly from the TCP connection.
190+
# [signer.reverse_proxy]
191+
# 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.
192+
# unique = "X-Real-IP"
193+
# 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.
194+
# rightmost = "X-Forwarded-For"
195+
188196
# [signer.tls_mode]
189197
# How to use TLS for the Signer's HTTP server; two modes are supported:
190198
# - type = "insecure": disable TLS, so the server runs in HTTP mode (not recommended for production).

crates/common/src/config/signer.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ pub enum TlsMode {
6969
Certificate(PathBuf),
7070
}
7171

72+
/// Reverse proxy setup, used to extract real client's IP
73+
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
74+
#[serde(rename_all = "snake_case")]
75+
pub enum ReverseProxyHeaderSetup {
76+
#[default]
77+
None,
78+
Unique(String),
79+
Rightmost(String),
80+
}
81+
7282
#[derive(Debug, Serialize, Deserialize, Clone)]
7383
#[serde(rename_all = "snake_case")]
7484
pub struct SignerConfig {
@@ -99,6 +109,10 @@ pub struct SignerConfig {
99109
#[serde(default = "default_tls_mode")]
100110
pub tls_mode: TlsMode,
101111

112+
/// Reverse proxy setup to extract real client's IP
113+
#[serde(default)]
114+
pub reverse_proxy: ReverseProxyHeaderSetup,
115+
102116
/// Inner type-specific configuration
103117
#[serde(flatten)]
104118
pub inner: SignerType,
@@ -194,6 +208,7 @@ pub struct StartSignerConfig {
194208
pub jwt_auth_fail_timeout_seconds: u32,
195209
pub dirk: Option<DirkConfig>,
196210
pub tls_certificates: Option<(Vec<u8>, Vec<u8>)>,
211+
pub reverse_proxy: ReverseProxyHeaderSetup,
197212
}
198213

199214
impl StartSignerConfig {
@@ -247,6 +262,8 @@ impl StartSignerConfig {
247262
}
248263
};
249264

265+
let reverse_proxy = signer_config.reverse_proxy;
266+
250267
match signer_config.inner {
251268
SignerType::Local { loader, store, .. } => Ok(StartSignerConfig {
252269
chain: config.chain,
@@ -259,6 +276,7 @@ impl StartSignerConfig {
259276
store,
260277
dirk: None,
261278
tls_certificates,
279+
reverse_proxy,
262280
}),
263281

264282
SignerType::Dirk {
@@ -305,6 +323,7 @@ impl StartSignerConfig {
305323
},
306324
}),
307325
tls_certificates,
326+
reverse_proxy,
308327
})
309328
}
310329

crates/signer/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ bimap.workspace = true
1414
blsful.workspace = true
1515
cb-common.workspace = true
1616
cb-metrics.workspace = true
17-
client-ip.workspace = true
1817
eyre.workspace = true
1918
futures.workspace = true
2019
headers.workspace = true

crates/signer/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ pub mod manager;
44
mod metrics;
55
mod proto;
66
pub mod service;
7+
mod utils;

crates/signer/src/service.rs

Lines changed: 9 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,12 @@ use cb_common::{
3030
},
3131
response::{BlsSignResponse, EcdsaSignResponse},
3232
},
33-
config::{ModuleSigningConfig, StartSignerConfig},
33+
config::{ModuleSigningConfig, ReverseProxyHeaderSetup, StartSignerConfig},
3434
constants::{COMMIT_BOOST_COMMIT, COMMIT_BOOST_VERSION},
3535
types::{BlsPublicKey, Chain, Jwt, ModuleId, SignatureRequestInfo},
3636
utils::{decode_jwt, validate_admin_jwt, validate_jwt},
3737
};
3838
use cb_metrics::provider::MetricsProvider;
39-
use client_ip::*;
4039
use eyre::Context;
4140
use headers::{Authorization, authorization::Bearer};
4241
use parking_lot::RwLock as ParkingRwLock;
@@ -49,6 +48,7 @@ use crate::{
4948
error::SignerModuleError,
5049
manager::{SigningManager, dirk::DirkManager, local::LocalSigningManager},
5150
metrics::{SIGNER_METRICS_REGISTRY, SIGNER_STATUS, uri_to_tag},
51+
utils::get_true_ip,
5252
};
5353

5454
pub const REQUEST_MAX_BODY_LENGTH: usize = 1024 * 1024; // 1 MB
@@ -83,6 +83,9 @@ struct SigningState {
8383
// JWT auth failure settings
8484
jwt_auth_fail_limit: u32,
8585
jwt_auth_fail_timeout: Duration,
86+
87+
/// Header to extract the trusted client IP from
88+
reverse_proxy: ReverseProxyHeaderSetup,
8689
}
8790

8891
impl SigningService {
@@ -102,6 +105,7 @@ impl SigningService {
102105
jwt_auth_failures: Arc::new(ParkingRwLock::new(HashMap::new())),
103106
jwt_auth_fail_limit: config.jwt_auth_fail_limit,
104107
jwt_auth_fail_timeout: Duration::from_secs(config.jwt_auth_fail_timeout_seconds as u64),
108+
reverse_proxy: config.reverse_proxy,
105109
};
106110

107111
// Get the signer counts
@@ -122,6 +126,7 @@ impl SigningService {
122126
loaded_proxies,
123127
jwt_auth_fail_limit =? state.jwt_auth_fail_limit,
124128
jwt_auth_fail_timeout =? state.jwt_auth_fail_timeout,
129+
reverse_proxy =? state.reverse_proxy,
125130
"Starting signing service"
126131
);
127132

@@ -224,38 +229,6 @@ fn mark_jwt_failure(state: &SigningState, client_ip: IpAddr) {
224229
failure_info.last_failure = Instant::now();
225230
}
226231

227-
/// Get the true client IP from the request headers or fallback to the socket
228-
/// address
229-
fn get_true_ip(req_headers: &HeaderMap, addr: &SocketAddr) -> eyre::Result<IpAddr> {
230-
let ip_extractors = [
231-
cf_connecting_ip,
232-
cloudfront_viewer_address,
233-
fly_client_ip,
234-
rightmost_forwarded,
235-
rightmost_x_forwarded_for,
236-
true_client_ip,
237-
x_real_ip,
238-
];
239-
240-
// Run each extractor in order and return the first valid IP found
241-
for extractor in ip_extractors {
242-
match extractor(req_headers) {
243-
Ok(true_ip) => {
244-
return Ok(true_ip);
245-
}
246-
Err(e) => {
247-
match e {
248-
Error::AbsentHeader { .. } => continue, // Missing headers are fine
249-
_ => return Err(eyre::eyre!(e.to_string())), // Report anything else
250-
}
251-
}
252-
}
253-
}
254-
255-
// Fallback to the socket IP
256-
Ok(addr.ip())
257-
}
258-
259232
/// Authentication middleware layer
260233
async fn jwt_auth(
261234
State(state): State<SigningState>,
@@ -266,7 +239,7 @@ async fn jwt_auth(
266239
next: Next,
267240
) -> Result<Response, SignerModuleError> {
268241
// Check if the request needs to be rate limited
269-
let client_ip = get_true_ip(&req_headers, &addr).map_err(|e| {
242+
let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| {
270243
error!("Failed to get client IP: {e}");
271244
SignerModuleError::RequestError("failed to get client IP".to_string())
272245
})?;
@@ -376,7 +349,7 @@ async fn admin_auth(
376349
next: Next,
377350
) -> Result<Response, SignerModuleError> {
378351
// Check if the request needs to be rate limited
379-
let client_ip = get_true_ip(&req_headers, &addr).map_err(|e| {
352+
let client_ip = get_true_ip(&req_headers, &addr, &state.reverse_proxy).map_err(|e| {
380353
error!("Failed to get client IP: {e}");
381354
SignerModuleError::RequestError("failed to get client IP".to_string())
382355
})?;

crates/signer/src/utils.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use std::net::{IpAddr, SocketAddr};
2+
3+
use axum::http::HeaderMap;
4+
use cb_common::config::ReverseProxyHeaderSetup;
5+
6+
#[derive(Debug, thiserror::Error)]
7+
pub enum IpError {
8+
#[error("header `{0}` is not present")]
9+
NotPresent(String),
10+
#[error("header value has invalid characters")]
11+
HasInvalidCharacters,
12+
#[error("header value is not a valid IP address")]
13+
InvalidValue,
14+
#[error("header `{0}` appears multiple times but expected to be unique")]
15+
NotUnique(String),
16+
}
17+
18+
/// Get the true client IP from the request headers or fallback to the socket
19+
/// address
20+
pub fn get_true_ip(
21+
headers: &HeaderMap,
22+
addr: &SocketAddr,
23+
reverse_proxy: &ReverseProxyHeaderSetup,
24+
) -> Result<IpAddr, IpError> {
25+
match reverse_proxy {
26+
ReverseProxyHeaderSetup::None => Ok(addr.ip()),
27+
ReverseProxyHeaderSetup::Unique(header) => get_ip_from_unique_header(headers, header),
28+
ReverseProxyHeaderSetup::Rightmost(header) => get_ip_from_rightmost_value(headers, header),
29+
}
30+
}
31+
32+
fn get_ip_from_unique_header(headers: &HeaderMap, header_name: &str) -> Result<IpAddr, IpError> {
33+
let mut values = headers.get_all(header_name).iter();
34+
35+
let first_value = values.next().ok_or(IpError::NotPresent(header_name.to_string()))?;
36+
let ip = first_value
37+
.to_str()
38+
.map_err(|_| IpError::HasInvalidCharacters)?
39+
.parse::<IpAddr>()
40+
.map_err(|_| IpError::InvalidValue)?;
41+
42+
if values.next().is_some() {
43+
return Err(IpError::NotUnique(header_name.to_string()));
44+
}
45+
46+
Ok(ip)
47+
}
48+
49+
fn get_ip_from_rightmost_value(headers: &HeaderMap, header_name: &str) -> Result<IpAddr, IpError> {
50+
let last_value = headers
51+
.get_all(header_name)
52+
.iter()
53+
.next_back()
54+
.ok_or(IpError::NotPresent(header_name.to_string()))?
55+
.to_str()
56+
.map_err(|_| IpError::HasInvalidCharacters)?;
57+
58+
last_value
59+
.rsplit_once(",")
60+
.map(|(_, rightmost)| rightmost)
61+
.unwrap_or(last_value)
62+
.parse::<IpAddr>()
63+
.map_err(|_| IpError::InvalidValue)
64+
}

docs/docs/get_started/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,33 @@ path = "path/to/your/cert/folder"
388388

389389
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).
390390

391+
### Rate limit
392+
393+
The Signer service implements a rate limit system of 3 failed authentications every 5 minutes. These values can be modified in the config file:
394+
395+
```toml
396+
[signer]
397+
...
398+
jwt_auth_fail_limit = 3 # The amount of failed requests allowed
399+
jwt_auth_fail_timeout_seconds = 300 # The time window in seconds
400+
```
401+
402+
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:
403+
404+
- `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.
405+
- `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`.
406+
407+
Examples:
408+
409+
```toml
410+
[signer.reverse_proxy]
411+
unique = "X-Real-IP"
412+
```
413+
414+
```toml
415+
[signer.reverse_proxy]
416+
rightmost = "X-Forwarded-For"
417+
```
391418

392419
## Custom module
393420

tests/src/utils.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ use alloy::primitives::{B256, U256};
99
use cb_common::{
1010
config::{
1111
CommitBoostConfig, LogsSettings, ModuleKind, ModuleSigningConfig, PbsConfig,
12-
PbsModuleConfig, RelayConfig, SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT,
13-
SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT, SIGNER_PORT_DEFAULT, SignerConfig,
14-
SignerType, StartSignerConfig, StaticModuleConfig, StaticPbsConfig, TlsMode,
12+
PbsModuleConfig, RelayConfig, ReverseProxyHeaderSetup, SIGNER_IMAGE_DEFAULT,
13+
SIGNER_JWT_AUTH_FAIL_LIMIT_DEFAULT, SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT,
14+
SIGNER_PORT_DEFAULT, SignerConfig, SignerType, StartSignerConfig, StaticModuleConfig,
15+
StaticPbsConfig, TlsMode,
1516
},
1617
pbs::{RelayClient, RelayEntry},
1718
signer::SignerLoader,
@@ -134,6 +135,7 @@ pub fn get_signer_config(loader: SignerLoader, tls: bool) -> SignerConfig {
134135
jwt_auth_fail_timeout_seconds: SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_DEFAULT,
135136
inner: SignerType::Local { loader, store: None },
136137
tls_mode: if tls { TlsMode::Certificate(PathBuf::new()) } else { TlsMode::Insecure },
138+
reverse_proxy: ReverseProxyHeaderSetup::None,
137139
}
138140
}
139141

@@ -169,6 +171,7 @@ pub fn get_start_signer_config(
169171
jwt_auth_fail_timeout_seconds: signer_config.jwt_auth_fail_timeout_seconds,
170172
dirk: None,
171173
tls_certificates,
174+
reverse_proxy: ReverseProxyHeaderSetup::None,
172175
},
173176
_ => panic!("Only local signers are supported in tests"),
174177
}

0 commit comments

Comments
 (0)