Skip to content

Commit a78f57c

Browse files
jclapisltitanb
andauthored
CBST2-06: Implement rate limiting for JWT auth failures (#310)
Co-authored-by: eltitanb <[email protected]> Co-authored-by: ltitanb <[email protected]>
1 parent f51f5bd commit a78f57c

File tree

16 files changed

+416
-25
lines changed

16 files changed

+416
-25
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ color-eyre = "0.6.3"
3636
ctr = "0.9.2"
3737
derive_more = { version = "2.0.1", features = ["deref", "display", "from", "into"] }
3838
docker-compose-types = "0.16.0"
39+
docker-image = "0.2.1"
3940
eth2_keystore = { git = "https://github.com/sigp/lighthouse", tag = "v7.0.1" }
4041
ethereum_serde_utils = "0.7.0"
4142
ethereum_ssz = "0.8"
@@ -58,6 +59,7 @@ serde_json = "1.0.117"
5859
serde_yaml = "0.9.33"
5960
sha2 = "0.10.8"
6061
ssz_types = "0.10"
62+
tempfile = "3.20.0"
6163
thiserror = "2.0.12"
6264
tokio = { version = "1.37.0", features = ["full"] }
6365
toml = "0.8.13"

config.example.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ host = "127.0.0.1"
154154
# Port to listen for Signer API calls on
155155
# OPTIONAL, DEFAULT: 20000
156156
port = 20000
157+
# Number of JWT authentication attempts a client can fail before blocking that client temporarily from Signer access
158+
# OPTIONAL, DEFAULT: 3
159+
jwt_auth_fail_limit = 3
160+
# How long to block a client from Signer access, in seconds, if it failed JWT authentication too many times
161+
# OPTIONAL, DEFAULT: 300
162+
jwt_auth_fail_timeout_seconds = 300
157163

158164
# For Remote signer:
159165
# [signer.remote]

crates/common/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ blst.workspace = true
1616
cipher.workspace = true
1717
ctr.workspace = true
1818
derive_more.workspace = true
19+
docker-image.workspace = true
1920
eth2_keystore.workspace = true
2021
ethereum_serde_utils.workspace = true
2122
ethereum_ssz.workspace = true

crates/common/src/config/constants.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ pub const SIGNER_MODULE_NAME: &str = "signer";
3535
/// Where the signer module should open the server
3636
pub const SIGNER_ENDPOINT_ENV: &str = "CB_SIGNER_ENDPOINT";
3737

38+
// JWT authentication settings
39+
pub const SIGNER_JWT_AUTH_FAIL_LIMIT_ENV: &str = "CB_SIGNER_JWT_AUTH_FAIL_LIMIT";
40+
pub const SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV: &str =
41+
"CB_SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS";
42+
3843
/// Comma separated list module_id=jwt_secret
3944
pub const JWTS_ENV: &str = "CB_JWTS";
4045

crates/common/src/config/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ impl CommitBoostConfig {
4141
/// Validate config
4242
pub async fn validate(&self) -> Result<()> {
4343
self.pbs.pbs_config.validate(self.chain).await?;
44+
if let Some(signer) = &self.signer {
45+
signer.validate().await?;
46+
}
4447
Ok(())
4548
}
4649

crates/common/src/config/signer.rs

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@ use std::{
44
path::PathBuf,
55
};
66

7-
use eyre::{bail, OptionExt, Result};
7+
use docker_image::DockerImage;
8+
use eyre::{bail, ensure, OptionExt, Result};
89
use serde::{Deserialize, Serialize};
910
use tonic::transport::{Certificate, Identity};
1011
use url::Url;
1112

1213
use super::{
1314
load_jwt_secrets, load_optional_env_var, utils::load_env_var, CommitBoostConfig,
14-
SIGNER_ENDPOINT_ENV, SIGNER_IMAGE_DEFAULT,
15+
SIGNER_ENDPOINT_ENV, SIGNER_IMAGE_DEFAULT, SIGNER_JWT_AUTH_FAIL_LIMIT_ENV,
16+
SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV,
1517
};
1618
use crate::{
1719
config::{DIRK_CA_CERT_ENV, DIRK_CERT_ENV, DIRK_DIR_SECRETS_ENV, DIRK_KEY_ENV},
18-
signer::{ProxyStore, SignerLoader, DEFAULT_SIGNER_PORT},
20+
signer::{
21+
ProxyStore, SignerLoader, DEFAULT_JWT_AUTH_FAIL_LIMIT,
22+
DEFAULT_JWT_AUTH_FAIL_TIMEOUT_SECONDS, DEFAULT_SIGNER_PORT,
23+
},
1924
types::{Chain, ModuleId},
20-
utils::{default_host, default_u16},
25+
utils::{default_host, default_u16, default_u32},
2126
};
2227

2328
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -32,11 +37,39 @@ pub struct SignerConfig {
3237
/// Docker image of the module
3338
#[serde(default = "default_signer")]
3439
pub docker_image: String,
40+
41+
/// Number of JWT auth failures before rate limiting an endpoint
42+
/// If set to 0, no rate limiting will be applied
43+
#[serde(default = "default_u32::<DEFAULT_JWT_AUTH_FAIL_LIMIT>")]
44+
pub jwt_auth_fail_limit: u32,
45+
46+
/// Duration in seconds to rate limit an endpoint after the JWT auth failure
47+
/// limit has been reached
48+
#[serde(default = "default_u32::<DEFAULT_JWT_AUTH_FAIL_TIMEOUT_SECONDS>")]
49+
pub jwt_auth_fail_timeout_seconds: u32,
50+
3551
/// Inner type-specific configuration
3652
#[serde(flatten)]
3753
pub inner: SignerType,
3854
}
3955

56+
impl SignerConfig {
57+
/// Validate the signer config
58+
pub async fn validate(&self) -> Result<()> {
59+
// Port must be positive
60+
ensure!(self.port > 0, "Port must be positive");
61+
62+
// The Docker tag must parse
63+
ensure!(!self.docker_image.is_empty(), "Docker image is empty");
64+
ensure!(
65+
DockerImage::parse(&self.docker_image).is_ok(),
66+
format!("Invalid Docker image: {}", self.docker_image)
67+
);
68+
69+
Ok(())
70+
}
71+
}
72+
4073
fn default_signer() -> String {
4174
SIGNER_IMAGE_DEFAULT.to_string()
4275
}
@@ -100,6 +133,8 @@ pub struct StartSignerConfig {
100133
pub store: Option<ProxyStore>,
101134
pub endpoint: SocketAddr,
102135
pub jwts: HashMap<ModuleId, String>,
136+
pub jwt_auth_fail_limit: u32,
137+
pub jwt_auth_fail_timeout_seconds: u32,
103138
pub dirk: Option<DirkConfig>,
104139
}
105140

@@ -119,12 +154,31 @@ impl StartSignerConfig {
119154
SocketAddr::from((signer_config.host, signer_config.port))
120155
};
121156

157+
// Load the JWT auth fail limit the same way
158+
let jwt_auth_fail_limit =
159+
if let Some(limit) = load_optional_env_var(SIGNER_JWT_AUTH_FAIL_LIMIT_ENV) {
160+
limit.parse()?
161+
} else {
162+
signer_config.jwt_auth_fail_limit
163+
};
164+
165+
// Load the JWT auth fail timeout the same way
166+
let jwt_auth_fail_timeout_seconds = if let Some(timeout) =
167+
load_optional_env_var(SIGNER_JWT_AUTH_FAIL_TIMEOUT_SECONDS_ENV)
168+
{
169+
timeout.parse()?
170+
} else {
171+
signer_config.jwt_auth_fail_timeout_seconds
172+
};
173+
122174
match signer_config.inner {
123175
SignerType::Local { loader, store, .. } => Ok(StartSignerConfig {
124176
chain: config.chain,
125177
loader: Some(loader),
126178
endpoint,
127179
jwts,
180+
jwt_auth_fail_limit,
181+
jwt_auth_fail_timeout_seconds,
128182
store,
129183
dirk: None,
130184
}),
@@ -153,6 +207,8 @@ impl StartSignerConfig {
153207
chain: config.chain,
154208
endpoint,
155209
jwts,
210+
jwt_auth_fail_limit,
211+
jwt_auth_fail_timeout_seconds,
156212
loader: None,
157213
store,
158214
dirk: Some(DirkConfig {

crates/common/src/signer/constants.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
pub const DEFAULT_SIGNER_PORT: u16 = 20000;
2+
3+
// Rate limit signer API requests for 5 minutes after the endpoint has 3 JWT
4+
// auth failures
5+
pub const DEFAULT_JWT_AUTH_FAIL_LIMIT: u32 = 3;
6+
pub const DEFAULT_JWT_AUTH_FAIL_TIMEOUT_SECONDS: u32 = 5 * 60;

crates/signer/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ futures.workspace = true
1818
headers.workspace = true
1919
jsonwebtoken.workspace = true
2020
lazy_static.workspace = true
21+
parking_lot.workspace = true
2122
prometheus.workspace = true
2223
prost.workspace = true
2324
rand.workspace = true

crates/signer/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pub enum SignerModuleError {
2727

2828
#[error("internal error: {0}")]
2929
Internal(String),
30+
31+
#[error("rate limited for {0} more seconds")]
32+
RateLimited(f64),
3033
}
3134

3235
impl IntoResponse for SignerModuleError {
@@ -45,6 +48,9 @@ impl IntoResponse for SignerModuleError {
4548
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
4649
}
4750
SignerModuleError::SignerError(err) => (StatusCode::BAD_REQUEST, err.to_string()),
51+
SignerModuleError::RateLimited(duration) => {
52+
(StatusCode::TOO_MANY_REQUESTS, format!("rate limited for {duration:?}"))
53+
}
4854
}
4955
.into_response()
5056
}

0 commit comments

Comments
 (0)