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

@@ -82,6 +82,7 @@ pub async fn agent_ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: axum::http::HeaderMap,
Query(params): Query<AgentParams>,
) -> Result<impl IntoResponse, StatusCode> {
// The CLIENT-SUPPLIED agent_id. For a per-agent-key (managed) agent this is
@@ -96,7 +97,12 @@ pub async fn agent_ws_handler(
.unwrap_or_else(|| agent_id.clone());
let support_code = params.support_code.clone();
let api_key = params.api_key.clone();
let client_ip = addr.ip();
// Real client IP via the trusted-proxy-aware extractor (shared with the rate
// limiter and audit log). Behind NPM on loopback, `addr.ip()` is 127.0.0.1;
// this resolves the actual remote agent IP from forwarding headers when the
// peer is a trusted proxy, and ignores those headers (using the peer) when it
// is not — so the connection-rejected events below record the real client.
let client_ip = crate::utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies);
// SECURITY: Agent must provide either a support code OR an API key
// Support code = ad-hoc support session (technician generated code)
@@ -408,9 +414,13 @@ pub async fn viewer_ws_handler(
ws: WebSocketUpgrade,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: axum::http::HeaderMap,
Query(params): Query<ViewerParams>,
) -> Result<impl IntoResponse, StatusCode> {
let client_ip = addr.ip();
// Real client IP via the trusted-proxy-aware extractor (shared with the rate
// limiter and audit log) so the viewer audit events record the real client,
// not the proxy's loopback address.
let client_ip = crate::utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies);
// The session the viewer is asking to join. Parse early — a malformed UUID
// can never match a viewer token's `session_id` claim, so reject up front.