Skip to content

Commit 9897d89

Browse files
authored
Hardening/control plane security (#62)
1 parent b5809f1 commit 9897d89

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1691
-1142
lines changed

Cargo.lock

Lines changed: 2 additions & 7 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 & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,10 @@ bon = "3"
3333
anyhow = "1"
3434
snafu = "0.8"
3535

36-
# System
37-
num_cpus = "1"
38-
3936
# Serialization
4037
bytes = "1"
4138
serde = { version = "1", features = ["derive"] }
4239
serde_json = "1"
43-
serde_yaml = "0.9"
4440

4541
# Tracing and observability
4642
metrics = "0.24"
@@ -68,9 +64,6 @@ tower-http = { version = "0.6", features = [
6864
clap = { version = "4", features = ["derive", "env"] }
6965
config = "0.15"
7066

71-
# Concurrency
72-
parking_lot = "0.12"
73-
7467
# Random number generation
7568
rand = "0.10"
7669

crates/api/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ ed25519-dalek = { workspace = true }
3030
jsonwebtoken = { workspace = true }
3131
metrics-exporter-prometheus = { workspace = true }
3232
moka = { workspace = true }
33-
pem = { workspace = true }
3433
rand = { workspace = true }
3534
serde = { workspace = true }
3635
serde_json = { workspace = true }

crates/api/src/extract.rs

Lines changed: 99 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,51 @@
1-
use std::net::SocketAddr;
1+
use std::{net::SocketAddr, num::NonZeroU8};
22

33
use axum::extract::{ConnectInfo, Request};
44

55
/// Extract the client IP address from a request.
66
///
7-
/// Checks proxy headers first, then falls back to the TCP peer address:
8-
/// 1. `X-Forwarded-For` — first IP in the comma-separated list
9-
/// 2. `X-Real-IP` — single IP set by the reverse proxy
10-
/// 3. `ConnectInfo<SocketAddr>` — TCP peer address (direct connection)
11-
pub fn extract_client_ip(req: &Request) -> Option<String> {
7+
/// Behavior depends on `trusted_proxy_depth`:
8+
///
9+
/// - `Some(n)`: Takes the client IP from `X-Forwarded-For` by skipping the `n` rightmost entries
10+
/// (which were appended by trusted proxy infrastructure). Returns `None` if the header has fewer
11+
/// entries than required. Falls back to `ConnectInfo` only when the header is absent.
12+
///
13+
/// - `None` (direct connection mode): Uses only `ConnectInfo` (TCP peer address). This is the safe
14+
/// default when not behind a reverse proxy.
15+
pub fn extract_client_ip(req: &Request, trusted_proxy_depth: Option<NonZeroU8>) -> Option<String> {
16+
match trusted_proxy_depth {
17+
Some(depth) => extract_with_proxy_depth(req, depth),
18+
None => extract_direct(req),
19+
}
20+
}
21+
22+
/// Extract IP using rightmost-nth selection from `X-Forwarded-For`.
23+
///
24+
/// With `depth=1` (one trusted proxy), the proxy appended the rightmost entry,
25+
/// so the client IP is at position `len - 2` (just before the proxy entry).
26+
/// With `depth=2` (two proxies), it's at `len - 3`, etc.
27+
fn extract_with_proxy_depth(req: &Request, depth: NonZeroU8) -> Option<String> {
1228
let headers = req.headers();
1329

14-
headers
15-
.get("x-forwarded-for")
16-
.and_then(|v| v.to_str().ok())
17-
.and_then(|s| s.split(',').next())
18-
.map(|s| s.trim().to_string())
19-
.filter(|s| !s.is_empty())
20-
.or_else(|| {
21-
headers
22-
.get("x-real-ip")
23-
.and_then(|v| v.to_str().ok())
24-
.map(|s| s.trim().to_string())
25-
.filter(|s| !s.is_empty())
26-
})
27-
.or_else(|| {
28-
req.extensions()
29-
.get::<ConnectInfo<SocketAddr>>()
30-
.map(|ConnectInfo(addr)| addr.ip().to_string())
31-
})
30+
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
31+
let ips: Vec<&str> = xff.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
32+
33+
// The real client IP is at position len - depth - 1 (just before proxy entries).
34+
let index = ips.len().checked_sub(depth.get() as usize + 1)?;
35+
let ip = ips.get(index)?;
36+
return Some((*ip).to_string());
37+
}
38+
39+
// Fall back to ConnectInfo if X-Forwarded-For is absent
40+
req.extensions().get::<ConnectInfo<SocketAddr>>().map(|ConnectInfo(addr)| addr.ip().to_string())
41+
}
42+
43+
/// Extract IP in direct connection mode (no trusted proxies).
44+
///
45+
/// Uses only `ConnectInfo` (TCP peer address). Proxy headers are ignored
46+
/// because without trusted proxy configuration they are freely spoofable.
47+
fn extract_direct(req: &Request) -> Option<String> {
48+
req.extensions().get::<ConnectInfo<SocketAddr>>().map(|ConnectInfo(addr)| addr.ip().to_string())
3249
}
3350

3451
#[cfg(test)]
@@ -54,78 +71,94 @@ mod tests {
5471
req
5572
}
5673

74+
// ── Direct mode (trusted_proxy_depth = None) ──────────────────
75+
5776
#[test]
58-
fn returns_x_forwarded_for_first_ip() {
59-
let req = request_with_headers(&[(
60-
"x-forwarded-for",
61-
"203.0.113.50, 70.41.3.18, 150.172.238.178",
62-
)]);
63-
assert_eq!(extract_client_ip(&req).as_deref(), Some("203.0.113.50"));
77+
fn direct_mode_prefers_connect_info() {
78+
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 12345);
79+
let mut req = request_with_headers(&[("x-forwarded-for", "203.0.113.50")]);
80+
req.extensions_mut().insert(ConnectInfo(addr));
81+
assert_eq!(extract_client_ip(&req, None).as_deref(), Some("10.0.0.1"));
6482
}
6583

6684
#[test]
67-
fn returns_x_forwarded_for_single_ip() {
68-
let req = request_with_headers(&[("x-forwarded-for", "203.0.113.50")]);
69-
assert_eq!(extract_client_ip(&req).as_deref(), Some("203.0.113.50"));
85+
fn direct_mode_ignores_proxy_headers() {
86+
let req = request_with_headers(&[
87+
("x-forwarded-for", "203.0.113.50"),
88+
("x-real-ip", "198.51.100.42"),
89+
]);
90+
// Without ConnectInfo, direct mode returns None — proxy headers are untrusted
91+
assert_eq!(extract_client_ip(&req, None), None);
7092
}
7193

7294
#[test]
73-
fn returns_x_real_ip_when_no_forwarded_for() {
74-
let req = request_with_headers(&[("x-real-ip", "198.51.100.42")]);
75-
assert_eq!(extract_client_ip(&req).as_deref(), Some("198.51.100.42"));
95+
fn direct_mode_returns_none_without_any_source() {
96+
let req = request_with_headers(&[]);
97+
assert_eq!(extract_client_ip(&req, None), None);
98+
}
99+
100+
// ── Proxy mode (trusted_proxy_depth = Some(n)) ────────────────
101+
102+
fn depth(n: u8) -> Option<NonZeroU8> {
103+
Some(NonZeroU8::new(n).unwrap())
76104
}
77105

78106
#[test]
79-
fn prefers_x_forwarded_for_over_x_real_ip() {
80-
let req = request_with_headers(&[
81-
("x-forwarded-for", "203.0.113.50"),
82-
("x-real-ip", "198.51.100.42"),
83-
]);
84-
assert_eq!(extract_client_ip(&req).as_deref(), Some("203.0.113.50"));
107+
fn proxy_depth_1_takes_client_ip() {
108+
let req =
109+
request_with_headers(&[("x-forwarded-for", "spoofed.ip, 203.0.113.50, 10.0.0.1")]);
110+
// depth=1: one trusted proxy appended 10.0.0.1, client is 203.0.113.50
111+
assert_eq!(extract_client_ip(&req, depth(1)).as_deref(), Some("203.0.113.50"));
85112
}
86113

87114
#[test]
88-
fn falls_back_to_connect_info() {
89-
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345);
90-
let req = request_with_connect_info(addr);
91-
assert_eq!(extract_client_ip(&req).as_deref(), Some("127.0.0.1"));
115+
fn proxy_depth_2_takes_second_from_right() {
116+
let req = request_with_headers(&[(
117+
"x-forwarded-for",
118+
"spoofed.ip, 203.0.113.50, 10.0.0.1, 10.0.0.2",
119+
)]);
120+
// depth=2: two proxies appended 10.0.0.1 and 10.0.0.2
121+
assert_eq!(extract_client_ip(&req, depth(2)).as_deref(), Some("203.0.113.50"));
92122
}
93123

94124
#[test]
95-
fn returns_none_without_any_source() {
96-
let req = request_with_headers(&[]);
97-
assert_eq!(extract_client_ip(&req), None);
125+
fn proxy_depth_with_single_ip_falls_back() {
126+
let req = request_with_headers(&[("x-forwarded-for", "203.0.113.50")]);
127+
// depth=1 with single IP: the proxy appended this IP, so there's no client
128+
// IP before it. Falls back to None (no ConnectInfo set).
129+
assert_eq!(extract_client_ip(&req, depth(1)), None);
98130
}
99131

100132
#[test]
101-
fn skips_empty_forwarded_for() {
102-
let req = request_with_headers(&[("x-forwarded-for", "")]);
103-
assert_eq!(extract_client_ip(&req), None);
133+
fn proxy_depth_with_two_ips() {
134+
let req = request_with_headers(&[("x-forwarded-for", "203.0.113.50, 10.0.0.1")]);
135+
// depth=1: proxy added 10.0.0.1, client is 203.0.113.50
136+
assert_eq!(extract_client_ip(&req, depth(1)).as_deref(), Some("203.0.113.50"));
104137
}
105138

106139
#[test]
107-
fn skips_empty_real_ip() {
108-
let req = request_with_headers(&[("x-real-ip", " ")]);
109-
assert_eq!(extract_client_ip(&req), None);
140+
fn proxy_depth_exceeds_ip_count_returns_none() {
141+
let req = request_with_headers(&[("x-forwarded-for", "203.0.113.50")]);
142+
// depth=3 but only 1 IP — can't extract
143+
assert_eq!(extract_client_ip(&req, depth(3)), None);
110144
}
111145

112146
#[test]
113-
fn trims_whitespace_from_forwarded_for() {
114-
let req = request_with_headers(&[("x-forwarded-for", " 203.0.113.50 , 70.41.3.18")]);
115-
assert_eq!(extract_client_ip(&req).as_deref(), Some("203.0.113.50"));
147+
fn proxy_mode_falls_back_to_connect_info_without_xff() {
148+
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 12345);
149+
let req = request_with_connect_info(addr);
150+
assert_eq!(extract_client_ip(&req, depth(1)).as_deref(), Some("127.0.0.1"));
116151
}
117152

118153
#[test]
119-
fn trims_whitespace_from_real_ip() {
120-
let req = request_with_headers(&[("x-real-ip", " 198.51.100.42 ")]);
121-
assert_eq!(extract_client_ip(&req).as_deref(), Some("198.51.100.42"));
154+
fn proxy_mode_trims_whitespace() {
155+
let req = request_with_headers(&[("x-forwarded-for", " 203.0.113.50 , 10.0.0.1 ")]);
156+
assert_eq!(extract_client_ip(&req, depth(1)).as_deref(), Some("203.0.113.50"));
122157
}
123158

124159
#[test]
125-
fn prefers_headers_over_connect_info() {
126-
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 12345);
127-
let mut req = request_with_headers(&[("x-forwarded-for", "203.0.113.50")]);
128-
req.extensions_mut().insert(ConnectInfo(addr));
129-
assert_eq!(extract_client_ip(&req).as_deref(), Some("203.0.113.50"));
160+
fn proxy_mode_skips_empty_xff() {
161+
let req = request_with_headers(&[("x-forwarded-for", "")]);
162+
assert_eq!(extract_client_ip(&req, depth(1)), None);
130163
}
131164
}

crates/api/src/handlers/audit_logs.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ use inferadb_control_types::Error as CoreError;
1515
use inferadb_ledger_sdk::{EventFilter, EventOutcome, OrganizationSlug};
1616
use serde::{Deserialize, Serialize};
1717

18-
use super::common::require_ledger;
18+
use super::common::{require_ledger, verify_org_membership_from_claims};
1919
use crate::{
20-
handlers::auth::{AppState, Result},
20+
handlers::state::{AppState, Result},
2121
middleware::UserClaims,
2222
};
2323

@@ -90,9 +90,9 @@ fn parse_outcome_filter(outcome: &str) -> std::result::Result<EventFilter, CoreE
9090
"success" => Ok(filter.outcome_success()),
9191
"failed" => Ok(filter.outcome_failed()),
9292
"denied" => Ok(filter.outcome_denied()),
93-
_ => Err(CoreError::validation(format!(
94-
"invalid outcome filter '{outcome}': expected 'success', 'failed', or 'denied'"
95-
))),
93+
_ => Err(CoreError::validation(
94+
"invalid outcome filter: expected 'success', 'failed', or 'denied'",
95+
)),
9696
}
9797
}
9898

@@ -128,25 +128,20 @@ pub async fn list_audit_logs(
128128
let ledger = require_ledger(&state)?;
129129
let organization = OrganizationSlug::new(org);
130130

131-
// Verify the caller is a member of this organization.
132-
let start = Instant::now();
133-
ledger
134-
.get_organization(organization, claims.user_slug)
135-
.await
136-
.map_sdk_err_instrumented("get_organization", start)?;
131+
verify_org_membership_from_claims(&state, ledger, org, &claims).await?;
137132

138133
let page = if let Some(ref page_token) = query.page_token {
139134
let start = Instant::now();
140135
ledger
141-
.list_events_next(organization, page_token)
136+
.list_events_next(claims.user_slug, organization, page_token)
142137
.await
143138
.map_sdk_err_instrumented("list_events_next", start)?
144139
} else {
145140
let filter = build_event_filter(&query)?;
146141
let limit = query.page_size.unwrap_or(50).clamp(1, 100);
147142
let start = Instant::now();
148143
ledger
149-
.list_events(organization, filter, limit)
144+
.list_events(claims.user_slug, organization, filter, limit)
150145
.await
151146
.map_sdk_err_instrumented("list_events", start)?
152147
};

0 commit comments

Comments
 (0)