fix(server): trusted-proxy client-IP extraction for rate-limit/audit keying
Resolves coord todo 3c1f372a (Task-4 review SHOULD-FIX). Behind NPM-on-loopback, ConnectInfo was 127.0.0.1 so the rate limiter + lockout bucketed every client under one IP. New shared utils::ip_extract::client_ip() honors X-Real-IP / X-Forwarded-For (rightmost-untrusted hop) ONLY when the TCP peer is a configured trusted proxy (CONNECT_TRUSTED_PROXIES env, default loopback, fail-closed); untrusted peers are keyed by their true peer IP (forged headers ignored). Wired into the 3 rate-limit middleware, the validate_code lockout feed, and the agent/ viewer WS handlers so the limiter, lockout, and audit ip_address all key on the real client consistently. 13 unit tests (spoof rejection, XFF walk, fail-safe defaults). Code-reviewed APPROVED. Not cargo-check-verified locally (no toolchain); build-host/CI verification follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,12 @@ pub struct AppState {
|
||||
/// Per-IP rate limiters + brute-force lockout (Task 4). Shared (Arc-backed
|
||||
/// internally) so cloning AppState shares the same counters.
|
||||
pub rate_limits: middleware::RateLimitState,
|
||||
/// Trusted reverse-proxy allowlist for client-IP extraction. Forwarding
|
||||
/// headers (`X-Forwarded-For` / `X-Real-IP`) are honored ONLY when the TCP
|
||||
/// peer is in this set; otherwise the peer IP is used. Parsed once at startup
|
||||
/// from `CONNECT_TRUSTED_PROXIES` (default: loopback). See
|
||||
/// `utils::ip_extract::client_ip`.
|
||||
pub trusted_proxies: Arc<utils::ip_extract::TrustedProxies>,
|
||||
}
|
||||
|
||||
/// Middleware to inject JWT config and token blacklist into request extensions
|
||||
@@ -235,6 +241,20 @@ async fn main() -> Result<()> {
|
||||
info!("No AGENT_API_KEY set - persistent agents will need JWT token or support code");
|
||||
}
|
||||
|
||||
// Trusted reverse-proxy allowlist for real-client-IP extraction.
|
||||
// GuruConnect sits behind NPM on loopback, so axum's ConnectInfo reports the
|
||||
// proxy peer (127.0.0.1/::1), not the client. We only honor X-Forwarded-For /
|
||||
// X-Real-IP when the TCP peer is a trusted proxy; otherwise the header is
|
||||
// attacker-spoofable and is ignored. Default trust set is loopback; override
|
||||
// with CONNECT_TRUSTED_PROXIES (comma-separated IPs).
|
||||
let trusted_proxies = Arc::new(utils::ip_extract::TrustedProxies::from_env_value(
|
||||
std::env::var("CONNECT_TRUSTED_PROXIES").ok().as_deref(),
|
||||
));
|
||||
info!(
|
||||
"Trusted reverse-proxy set for client-IP extraction: [{}]",
|
||||
trusted_proxies.describe()
|
||||
);
|
||||
|
||||
// Initialize Prometheus metrics
|
||||
let mut registry = Registry::default();
|
||||
let metrics = Arc::new(metrics::Metrics::new(&mut registry));
|
||||
@@ -267,6 +287,7 @@ async fn main() -> Result<()> {
|
||||
registry,
|
||||
start_time,
|
||||
rate_limits: middleware::RateLimitState::new(),
|
||||
trusted_proxies,
|
||||
};
|
||||
|
||||
// Build router
|
||||
@@ -508,9 +529,14 @@ struct ValidateParams {
|
||||
async fn validate_code(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Path(code): Path<String>,
|
||||
) -> Json<CodeValidation> {
|
||||
let ip = addr.ip();
|
||||
// Real client IP via the trusted-proxy-aware extractor — must match the key
|
||||
// the lockout middleware (`code_validate_rate_limit`) uses, or the per-attempt
|
||||
// success/failure would be recorded against a different bucket than the one
|
||||
// the lockout is enforced on.
|
||||
let ip = utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies);
|
||||
|
||||
// PREVIEW ONLY: validate_code inspects the in-memory code state and does NOT
|
||||
// consume the code (single-use consumption happens at agent BIND, in
|
||||
|
||||
Reference in New Issue
Block a user