Skip to content

Commit 50827c2

Browse files
committed
security(cluster): confine insecure transport binds
1 parent e984aed commit 50827c2

7 files changed

Lines changed: 131 additions & 3 deletions

File tree

nodedb-cluster/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ pub use transport::{
143143
PinnedClientVerifier, PinnedServerVerifier, RaftRpcHandler, ShufflePushStream, TlsCredentials,
144144
TransportCredentials, TransportPeerSnapshot, VerifyMethod, VerifyOutcome, ca_fingerprint,
145145
ca_fingerprint_hex, generate_node_credentials, generate_node_credentials_multi_san,
146-
insecure_transport_count, issue_leaf_for_sans, load_crls_from_pem,
147-
make_raft_client_config_mtls, make_raft_server_config_mtls, spki_pin_from_cert_der,
146+
insecure_transport_bind_allowed, insecure_transport_count, issue_leaf_for_sans,
147+
load_crls_from_pem, make_raft_client_config_mtls, make_raft_server_config_mtls,
148+
spki_pin_from_cert_der,
148149
};
149150
pub use wire::VShardEnvelope;
150151

nodedb-cluster/src/transport/client/tests.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,24 @@ impl RaftRpcHandler for EchoHandler {
171171
async fn on_timeout_now(&self, _req: TimeoutNowRequest) {}
172172
}
173173

174+
#[test]
175+
fn insecure_transport_rejects_non_private_bind_before_observability_increment() {
176+
let before = crate::transport::credentials::insecure_transport_count();
177+
let error = match NexarTransport::new(
178+
1,
179+
"0.0.0.0:0".parse().unwrap(),
180+
TransportCredentials::Insecure,
181+
) {
182+
Ok(_) => panic!("unspecified insecure bind must be rejected"),
183+
Err(error) => error,
184+
};
185+
assert!(matches!(error, ClusterError::Config { .. }));
186+
assert_eq!(
187+
crate::transport::credentials::insecure_transport_count(),
188+
before
189+
);
190+
}
191+
174192
fn make_transport(node_id: u64) -> NexarTransport {
175193
NexarTransport::new(
176194
node_id,

nodedb-cluster/src/transport/client/transport.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,15 @@ impl NexarTransport {
155155
creds: TransportCredentials,
156156
identity_store: Arc<dyn PeerIdentityStore>,
157157
) -> Result<Self> {
158+
if creds.is_insecure() && !credentials::insecure_transport_bind_allowed(listen_addr) {
159+
return Err(ClusterError::Config {
160+
detail: format!(
161+
"insecure cluster transport requires a loopback or private bind address; \
162+
{listen_addr} is unspecified or publicly routable"
163+
),
164+
});
165+
}
166+
158167
let (server_config, client_config) = match &creds {
159168
TransportCredentials::Mtls(tls) => (
160169
config::make_raft_server_config_mtls(tls, tuning, Arc::clone(&identity_store))?,

nodedb-cluster/src/transport/credentials.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
//! [`NexarTransport`]: super::client::NexarTransport
1414
//! [`insecure_transport_count`]: self::insecure_transport_count
1515
16+
use std::net::{IpAddr, SocketAddr};
1617
use std::sync::atomic::{AtomicU64, Ordering};
1718

1819
use tracing::warn;
@@ -63,6 +64,18 @@ impl std::fmt::Debug for TransportCredentials {
6364

6465
static INSECURE_TRANSPORT_COUNT: AtomicU64 = AtomicU64::new(0);
6566

67+
/// Whether an unauthenticated cluster listener is confined to a loopback or
68+
/// private-network bind address.
69+
///
70+
/// Unspecified and globally routable addresses are deliberately rejected.
71+
/// IPv4 permits loopback and RFC 1918 space; IPv6 permits loopback and ULA.
72+
pub fn insecure_transport_bind_allowed(addr: SocketAddr) -> bool {
73+
match addr.ip() {
74+
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private(),
75+
IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local(),
76+
}
77+
}
78+
6679
/// Number of [`NexarTransport`] instances constructed in `Insecure` mode
6780
/// since process start. Exposed for operational dashboards — any non-zero
6881
/// value in a production deployment is a misconfiguration.
@@ -94,6 +107,38 @@ mod tests {
94107
assert!(TransportCredentials::Insecure.is_insecure());
95108
}
96109

110+
#[test]
111+
fn insecure_bind_policy_rejects_public_and_unspecified_addresses() {
112+
for addr in [
113+
"0.0.0.0:9400",
114+
"8.8.8.8:9400",
115+
"[::]:9400",
116+
"[2001:4860:4860::8888]:9400",
117+
] {
118+
assert!(
119+
!insecure_transport_bind_allowed(addr.parse().unwrap()),
120+
"{addr}"
121+
);
122+
}
123+
}
124+
125+
#[test]
126+
fn insecure_bind_policy_allows_loopback_and_private_addresses() {
127+
for addr in [
128+
"127.0.0.1:9400",
129+
"10.0.0.1:9400",
130+
"172.16.0.1:9400",
131+
"192.168.0.1:9400",
132+
"[::1]:9400",
133+
"[fd00::1]:9400",
134+
] {
135+
assert!(
136+
insecure_transport_bind_allowed(addr.parse().unwrap()),
137+
"{addr}"
138+
);
139+
}
140+
}
141+
97142
#[test]
98143
fn announce_bumps_counter() {
99144
let before = insecure_transport_count();

nodedb-cluster/src/transport/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ pub mod pki_types {
3030
CertificateDer, CertificateRevocationListDer, PrivateKeyDer, PrivatePkcs8KeyDer, UnixTime,
3131
};
3232
}
33-
pub use credentials::{TransportCredentials, insecure_transport_count};
33+
pub use credentials::{
34+
TransportCredentials, insecure_transport_bind_allowed, insecure_transport_count,
35+
};
3436
pub use peer_identity_store::{NoopIdentityStore, PeerIdentityStore};
3537
pub use peer_identity_verifier::{
3638
IDENTITY_MISMATCH_QUIC_ERROR, VerifyMethod, VerifyOutcome, spki_pin_from_cert_der,

nodedb/src/config/server/cluster.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,16 @@ impl ClusterSettings {
175175
detail: "cluster.replication_factor must be at least 1".into(),
176176
});
177177
}
178+
if self.insecure_transport && !nodedb_cluster::insecure_transport_bind_allowed(self.listen)
179+
{
180+
return Err(crate::Error::Config {
181+
detail: format!(
182+
"cluster.insecure_transport requires cluster.listen to use a loopback or \
183+
private address; {} is unspecified or publicly routable",
184+
self.listen
185+
),
186+
});
187+
}
178188
if self.force_bootstrap && !self.seed_nodes.contains(&self.listen) {
179189
return Err(crate::Error::Config {
180190
detail: "cluster.force_bootstrap requires cluster.listen to be present in \
@@ -185,3 +195,40 @@ impl ClusterSettings {
185195
Ok(())
186196
}
187197
}
198+
199+
#[cfg(test)]
200+
mod tests {
201+
use super::*;
202+
203+
fn insecure_settings(listen: &str) -> ClusterSettings {
204+
let listen = listen.parse().unwrap();
205+
ClusterSettings {
206+
node_id: 1,
207+
listen,
208+
seed_nodes: vec![listen],
209+
num_groups: 1,
210+
replication_factor: 1,
211+
force_bootstrap: false,
212+
tls: None,
213+
max_active_sessions: 0,
214+
login_attempts_per_ip_per_min: 30,
215+
login_attempts_per_user_per_min: 10,
216+
insecure_transport: true,
217+
log_compaction_threshold: None,
218+
}
219+
}
220+
221+
#[test]
222+
fn insecure_transport_rejects_public_and_unspecified_binds() {
223+
for listen in ["0.0.0.0:9400", "8.8.8.8:9400", "[::]:9400"] {
224+
assert!(insecure_settings(listen).validate().is_err(), "{listen}");
225+
}
226+
}
227+
228+
#[test]
229+
fn insecure_transport_allows_private_binds() {
230+
for listen in ["127.0.0.1:9400", "10.0.0.1:9400", "[fd00::1]:9400"] {
231+
assert!(insecure_settings(listen).validate().is_ok(), "{listen}");
232+
}
233+
}
234+
}

nodedb/src/control/metrics/prometheus/core.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ impl SystemMetrics {
7474
"Active vShard migrations",
7575
self.vshard_migrations_active.load(Ordering::Relaxed),
7676
);
77+
gauge(
78+
out,
79+
"nodedb_cluster_insecure_transports",
80+
"Insecure cluster transports created; alert whenever this is greater than zero",
81+
nodedb_cluster::insecure_transport_count(),
82+
);
7783

7884
// ── Bridge ──
7985
gauge_f64(

0 commit comments

Comments
 (0)