fix(server): trusted-proxy client-IP extraction for rate-limit/audit keying
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 5m9s
Build and Test / Build Agent (Windows) (push) Successful in 7m38s
Build and Test / Security Audit (push) Successful in 4m59s
Build and Test / Build Summary (push) Has been skipped

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:
2026-05-30 07:15:45 -07:00
parent 21189423f2
commit 5d5cd26572
4 changed files with 430 additions and 29 deletions

View File

@@ -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