Skip to main content

mockforge_proxy/
server.rs

1//! Browser/Mobile Proxy Server
2//!
3//! Provides an intercepting proxy for frontend/mobile clients with HTTPS support,
4//! certificate injection, and comprehensive request/response logging.
5
6use crate::{body_transform::BodyTransformationMiddleware, config::ProxyConfig};
7use axum::{
8    extract::Request, http::StatusCode, middleware::Next, response::Response, routing::get, Router,
9};
10use serde::Serialize;
11use std::net::SocketAddr;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14use tracing::{debug, error, info, warn};
15
16/// Proxy server state
17pub struct ProxyServer {
18    /// Proxy configuration
19    config: Arc<RwLock<ProxyConfig>>,
20    /// Request logging enabled
21    log_requests: bool,
22    /// Response logging enabled
23    log_responses: bool,
24    /// Request counter for logging
25    request_counter: Arc<RwLock<u64>>,
26    /// Server start time for uptime and rate calculations
27    start_time: std::time::Instant,
28    /// Total response time in milliseconds for average calculation
29    total_response_time_ms: Arc<RwLock<u64>>,
30    /// Error counter for error rate calculation
31    error_counter: Arc<RwLock<u64>>,
32    /// #864 — optional passive conformance tap over a loaded spec.
33    /// Built from MOCKFORGE_PROXY_SPEC + MOCKFORGE_PROXY_VALIDATE_CONFORMANCE.
34    conformance_tap: Option<crate::conformance::ConformanceTap>,
35}
36
37impl std::fmt::Debug for ProxyServer {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("ProxyServer")
40            .field("log_requests", &self.log_requests)
41            .field("log_responses", &self.log_responses)
42            .field("conformance_tap", &self.conformance_tap.as_ref().map(|_| "configured"))
43            .finish()
44    }
45}
46
47impl ProxyServer {
48    /// Create a new proxy server
49    pub fn new(config: ProxyConfig, log_requests: bool, log_responses: bool) -> Self {
50        // #864 — opt-in conformance tap. Requires a spec (env or config)
51        // and the validate flag; strict mode additionally rejects.
52        let want_validation = std::env::var("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE")
53            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
54            .unwrap_or(false);
55        let conformance_tap = if want_validation {
56            let strict = std::env::var("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE_STRICT")
57                .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
58                .unwrap_or(false);
59            match std::env::var("MOCKFORGE_PROXY_SPEC") {
60                Ok(path) if !path.trim().is_empty() => {
61                    match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(
62                        |raw| {
63                            serde_json::from_str::<serde_json::Value>(&raw)
64                                .or_else(|_| serde_yaml::from_str::<serde_json::Value>(&raw))
65                                .map_err(|e| e.to_string())
66                        },
67                    ) {
68                        Ok(spec_value) => {
69                            match crate::conformance::ConformanceTap::from_spec_value(
70                                spec_value, strict,
71                            ) {
72                                Ok(tap) => {
73                                    info!(
74                                        spec = %path,
75                                        strict,
76                                        "proxy conformance validation enabled (#864)"
77                                    );
78                                    Some(tap)
79                                }
80                                Err(e) => {
81                                    warn!(spec = %path, error = %e, "conformance tap disabled");
82                                    None
83                                }
84                            }
85                        }
86                        Err(e) => {
87                            warn!(spec = %path, error = %e, "cannot parse conformance spec; tap disabled");
88                            None
89                        }
90                    }
91                }
92                _ => {
93                    warn!("MOCKFORGE_PROXY_VALIDATE_CONFORMANCE set but MOCKFORGE_PROXY_SPEC missing; tap disabled");
94                    None
95                }
96            }
97        } else {
98            None
99        };
100        Self {
101            config: Arc::new(RwLock::new(config)),
102            log_requests,
103            log_responses,
104            request_counter: Arc::new(RwLock::new(0)),
105            start_time: std::time::Instant::now(),
106            total_response_time_ms: Arc::new(RwLock::new(0)),
107            error_counter: Arc::new(RwLock::new(0)),
108            conformance_tap,
109        }
110    }
111
112    /// Get the Axum router for the proxy server
113    pub fn router(self) -> Router {
114        let state = Arc::new(self);
115        let state_for_middleware = state.clone();
116
117        Router::new()
118            // Health check endpoint
119            .route("/proxy/health", get(health_check))
120            // Catch-all proxy handler - use fallback for all methods
121            .fallback(proxy_handler)
122            .with_state(state)
123            .layer(axum::middleware::from_fn_with_state(state_for_middleware, logging_middleware))
124    }
125}
126
127/// Health check endpoint for the proxy
128async fn health_check() -> Result<Response<String>, StatusCode> {
129    // Response builder should never fail with known-good values, but handle errors gracefully
130    Response::builder()
131        .status(StatusCode::OK)
132        .header("Content-Type", "application/json")
133        .body(r#"{"status":"healthy","service":"mockforge-proxy"}"#.to_string())
134        .map_err(|e| {
135            tracing::error!("Failed to build health check response: {}", e);
136            StatusCode::INTERNAL_SERVER_ERROR
137        })
138}
139
140/// Main proxy handler that intercepts and forwards requests
141async fn proxy_handler(
142    axum::extract::State(state): axum::extract::State<Arc<ProxyServer>>,
143    request: http::Request<axum::body::Body>,
144) -> Result<Response<String>, StatusCode> {
145    // Extract client address from request extensions (set by ConnectInfo middleware)
146    let client_addr = request
147        .extensions()
148        .get::<SocketAddr>()
149        .copied()
150        .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)));
151
152    let method = request.method().clone();
153    let uri = request.uri().clone();
154    let headers = request.headers().clone();
155
156    // Read request body early for conditional evaluation (consume the body)
157    let body_bytes = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
158        Ok(bytes) => Some(bytes.to_vec()),
159        Err(e) => {
160            error!("Failed to read request body: {}", e);
161            None
162        }
163    };
164
165    let config = state.config.read().await;
166
167    // Check if proxy is enabled
168    if !config.enabled {
169        return Err(StatusCode::SERVICE_UNAVAILABLE);
170    }
171
172    // Determine if this request should be proxied (with conditional evaluation)
173    if !config.should_proxy_with_condition(&method, &uri, &headers, body_bytes.as_deref()) {
174        return Err(StatusCode::NOT_FOUND);
175    }
176
177    // Get the stripped path (without proxy prefix)
178    let stripped_path = config.strip_prefix(uri.path());
179
180    // Get the base upstream URL and construct the full URL
181    let base_upstream_url = config.get_upstream_url(uri.path());
182    // strip_prefix re-adds a leading slash, so tolerate both `http://…`
183    // and `/http://…` spellings before testing for an absolute URL.
184    let candidate = stripped_path.trim_start_matches('/');
185    let is_absolute_upstream =
186        candidate.starts_with("http://") || candidate.starts_with("https://");
187
188    // #1012 / MF-002: an absolute URL embedded in the request path used to be
189    // forwarded verbatim — an open proxy/SSRF vector. It now requires the
190    // explicit `allow_absolute_url_upstream` opt-in, and even then passes the
191    // egress guard (denylisted IPs/metadata hosts, DNS re-check).
192    if is_absolute_upstream && !config.allow_absolute_url_upstream {
193        warn!(
194            path = %uri.path(),
195            "Rejected absolute-URL-in-path proxying; set allow_absolute_url_upstream=true to opt in (#1012)"
196        );
197        let body =
198            "Forbidden: absolute-URL proxying is disabled (allow_absolute_url_upstream=false)"
199                .to_string();
200        return Ok(Response::builder()
201            .status(StatusCode::FORBIDDEN)
202            .body(body)
203            .unwrap_or_else(|_| Response::new(String::new())));
204    }
205
206    let full_upstream_url = if is_absolute_upstream {
207        use crate::egress::{EgressDecision, EgressGuard};
208        let guard = EgressGuard::new(config.upstream_allowlist.clone());
209        match guard.check(candidate).await {
210            EgressDecision::Allowed => candidate.to_string(),
211            EgressDecision::Blocked(reason) => {
212                warn!(target = %stripped_path, %reason, "Blocked request-derived upstream by egress guard");
213                let body = format!("Forbidden: upstream blocked by egress guard ({})", reason);
214                return Ok(Response::builder()
215                    .status(StatusCode::FORBIDDEN)
216                    .body(body)
217                    .unwrap_or_else(|_| Response::new(String::new())));
218            }
219        }
220    } else {
221        let base = base_upstream_url.trim_end_matches('/');
222        let path = stripped_path.trim_start_matches('/');
223        let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default();
224        if path.is_empty() || path == "/" {
225            format!("{}{}", base, query)
226        } else {
227            format!("{}/{}", base, path) + &query
228        }
229    };
230
231    // Create a new URI with the full upstream URL for the proxy handler
232    let _modified_uri = full_upstream_url.parse::<http::Uri>().unwrap_or_else(|_| uri.clone());
233
234    // Log the request if enabled
235    if state.log_requests {
236        let mut counter = state.request_counter.write().await;
237        *counter += 1;
238        let request_id = *counter;
239
240        info!(
241            request_id = request_id,
242            method = %method,
243            path = %uri.path(),
244            upstream = %full_upstream_url,
245            client_ip = %client_addr.ip(),
246            "Proxy request intercepted"
247        );
248    }
249
250    // Convert headers to HashMap for the proxy handler
251    let mut header_map = std::collections::HashMap::new();
252    for (key, value) in &headers {
253        if let Ok(value_str) = value.to_str() {
254            header_map.insert(key.to_string(), value_str.to_string());
255        }
256    }
257
258    // Use ProxyClient directly with the full upstream URL to bypass ProxyHandler's URL construction
259    use crate::client::ProxyClient;
260    let proxy_client = ProxyClient::new();
261
262    // Convert method to reqwest method
263    let reqwest_method = match method.as_str() {
264        "GET" => reqwest::Method::GET,
265        "POST" => reqwest::Method::POST,
266        "PUT" => reqwest::Method::PUT,
267        "DELETE" => reqwest::Method::DELETE,
268        "HEAD" => reqwest::Method::HEAD,
269        "OPTIONS" => reqwest::Method::OPTIONS,
270        "PATCH" => reqwest::Method::PATCH,
271        _ => {
272            error!("Unsupported HTTP method: {}", method);
273            return Err(StatusCode::METHOD_NOT_ALLOWED);
274        }
275    };
276
277    // Add any configured headers
278    for (key, value) in &config.headers {
279        header_map.insert(key.clone(), value.clone());
280    }
281
282    // Apply request body transformations if configured
283    let mut transformed_request_body = body_bytes.clone();
284    if !config.request_replacements.is_empty() {
285        let transform_middleware = BodyTransformationMiddleware::new(
286            config.request_replacements.clone(),
287            Vec::new(), // No response rules needed here
288        );
289        if let Err(e) =
290            transform_middleware.transform_request_body(uri.path(), &mut transformed_request_body)
291        {
292            warn!("Failed to transform request body: {}", e);
293            // Continue with original body if transformation fails
294        }
295    }
296
297    // #864 — passive request validation against the loaded spec. The
298    // findings land in the shared conformance buffer (bench/TUI/admin read
299    // the same store). In STRICT mode a violation rejects with the spec
300    // status instead of forwarding.
301    if let Some(tap) = &state.conformance_tap {
302        if let Some((status, payload)) = tap
303            .validate_request(
304                method.as_str(),
305                uri.path(),
306                uri.query(),
307                &headers,
308                Some(transformed_request_body.as_deref().unwrap_or(&[])),
309            )
310            .await
311        {
312            warn!(status = status, path = %uri.path(), "strict conformance rejection");
313            return Response::builder()
314                .status(status)
315                .body(payload.to_string())
316                .map_err(|_| StatusCode::BAD_GATEWAY);
317        }
318    }
319
320    match proxy_client
321        .send_request(
322            reqwest_method,
323            &full_upstream_url,
324            &header_map,
325            transformed_request_body.as_deref(),
326        )
327        .await
328    {
329        Ok(response) => {
330            let status = StatusCode::from_u16(response.status().as_u16())
331                .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
332
333            // Log the response if enabled
334            if state.log_responses {
335                info!(
336                    method = %method,
337                    path = %uri.path(),
338                    status = status.as_u16(),
339                    "Proxy response sent"
340                );
341            }
342
343            // Convert response headers
344            let mut response_headers = http::HeaderMap::new();
345            for (name, value) in response.headers() {
346                if let (Ok(header_name), Ok(header_value)) = (
347                    http::HeaderName::try_from(name.as_str()),
348                    http::HeaderValue::try_from(value.as_bytes()),
349                ) {
350                    response_headers.insert(header_name, header_value);
351                }
352            }
353
354            // Read response body
355            let response_body_bytes = response.bytes().await.map_err(|e| {
356                error!("Failed to read proxy response body: {}", e);
357                StatusCode::BAD_GATEWAY
358            })?;
359
360            // Apply response body transformations if configured
361            let mut final_body_bytes = response_body_bytes.to_vec();
362            {
363                let config_for_response = state.config.read().await;
364                if !config_for_response.response_replacements.is_empty() {
365                    let transform_middleware = BodyTransformationMiddleware::new(
366                        Vec::new(), // No request rules needed here
367                        config_for_response.response_replacements.clone(),
368                    );
369                    let mut body_option = Some(final_body_bytes.clone());
370                    if let Err(e) = transform_middleware.transform_response_body(
371                        uri.path(),
372                        status.as_u16(),
373                        &mut body_option,
374                    ) {
375                        warn!("Failed to transform response body: {}", e);
376                        // Continue with original body if transformation fails
377                    } else if let Some(transformed_body) = body_option {
378                        final_body_bytes = transformed_body;
379                    }
380                }
381            }
382
383            // #864 — observational response validation against the spec.
384            if let Some(tap) = &state.conformance_tap {
385                tap.validate_response(
386                    method.as_str(),
387                    uri.path(),
388                    status.as_u16(),
389                    Some(final_body_bytes.as_slice()),
390                )
391                .await;
392            }
393
394            let body_string = String::from_utf8_lossy(&final_body_bytes).to_string();
395
396            // Build Axum response
397            let mut response_builder = Response::builder().status(status);
398            for (name, value) in response_headers.iter() {
399                response_builder = response_builder.header(name, value);
400            }
401
402            response_builder
403                .body(body_string)
404                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
405        }
406        Err(e) => {
407            error!("Proxy request failed: {}", e);
408            Err(StatusCode::BAD_GATEWAY)
409        }
410    }
411}
412
413/// Middleware for logging requests and responses
414async fn logging_middleware(
415    axum::extract::State(state): axum::extract::State<Arc<ProxyServer>>,
416    request: Request,
417    next: Next,
418) -> Response {
419    let start = std::time::Instant::now();
420    let method = request.method().clone();
421    let uri = request.uri().clone();
422
423    // Extract client address from request extensions
424    let client_addr = request
425        .extensions()
426        .get::<SocketAddr>()
427        .copied()
428        .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)));
429
430    debug!(
431        method = %method,
432        uri = %uri,
433        client_ip = %client_addr.ip(),
434        "Request received"
435    );
436
437    let response = next.run(request).await;
438    let duration = start.elapsed();
439
440    // Track response time
441    {
442        let mut total_time = state.total_response_time_ms.write().await;
443        *total_time += duration.as_millis() as u64;
444    }
445
446    // Track server errors
447    if response.status().is_server_error() {
448        let mut errors = state.error_counter.write().await;
449        *errors += 1;
450    }
451
452    debug!(
453        method = %method,
454        uri = %uri,
455        status = %response.status(),
456        duration_ms = duration.as_millis(),
457        "Response sent"
458    );
459
460    response
461}
462
463/// Proxy statistics for monitoring
464#[derive(Debug, Serialize)]
465pub struct ProxyStats {
466    /// Total requests processed
467    pub total_requests: u64,
468    /// Requests per second
469    pub requests_per_second: f64,
470    /// Average response time in milliseconds
471    pub avg_response_time_ms: f64,
472    /// Error rate percentage
473    pub error_rate_percent: f64,
474}
475
476/// Get proxy statistics
477pub async fn get_proxy_stats(state: &ProxyServer) -> ProxyStats {
478    let total_requests = *state.request_counter.read().await;
479    let total_response_time_ms = *state.total_response_time_ms.read().await;
480    let error_count = *state.error_counter.read().await;
481
482    let elapsed_secs = state.start_time.elapsed().as_secs_f64();
483    let requests_per_second = if elapsed_secs > 0.0 {
484        total_requests as f64 / elapsed_secs
485    } else {
486        0.0
487    };
488
489    let avg_response_time_ms = if total_requests > 0 {
490        total_response_time_ms as f64 / total_requests as f64
491    } else {
492        0.0
493    };
494
495    let error_rate_percent = if total_requests > 0 {
496        (error_count as f64 / total_requests as f64) * 100.0
497    } else {
498        0.0
499    };
500
501    ProxyStats {
502        total_requests,
503        requests_per_second,
504        avg_response_time_ms,
505        error_rate_percent,
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::config::ProxyConfig;
513    use axum::http::StatusCode;
514
515    #[tokio::test]
516    async fn test_proxy_server_creation() {
517        let config = ProxyConfig::default();
518        let server = ProxyServer::new(config, true, true);
519
520        // Test that the server can be created
521        assert!(server.log_requests);
522        assert!(server.log_responses);
523    }
524
525    #[tokio::test]
526    async fn test_absolute_url_in_path_rejected_by_default() {
527        let config = ProxyConfig {
528            enabled: true,
529            prefix: Some("/proxy/".to_string()),
530            ..Default::default()
531        };
532        // allow_absolute_url_upstream defaults to false (#1012)
533        let server = Arc::new(ProxyServer::new(config, false, false));
534
535        let request = http::Request::builder()
536            .method("GET")
537            .uri("/proxy/http://169.254.169.254/latest/meta-data/")
538            .body(axum::body::Body::empty())
539            .unwrap();
540        let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
541
542        assert_eq!(response.status(), StatusCode::FORBIDDEN);
543    }
544
545    #[tokio::test]
546    async fn test_opt_in_absolute_url_still_blocked_for_metadata_ip() {
547        let config = ProxyConfig {
548            enabled: true,
549            prefix: Some("/proxy/".to_string()),
550            allow_absolute_url_upstream: true,
551            ..Default::default()
552        };
553        let server = Arc::new(ProxyServer::new(config, false, false));
554
555        let request = http::Request::builder()
556            .method("GET")
557            .uri("/proxy/http://169.254.169.254/latest/meta-data/")
558            .body(axum::body::Body::empty())
559            .unwrap();
560        let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
561
562        assert_eq!(response.status(), StatusCode::FORBIDDEN);
563        assert!(response.into_body().contains("egress guard"));
564    }
565
566    #[tokio::test]
567    async fn test_opt_in_absolute_url_blocked_for_loopback() {
568        let config = ProxyConfig {
569            enabled: true,
570            prefix: Some("/proxy/".to_string()),
571            allow_absolute_url_upstream: true,
572            ..Default::default()
573        };
574        let server = Arc::new(ProxyServer::new(config, false, false));
575
576        let request = http::Request::builder()
577            .method("GET")
578            .uri("/proxy/http://127.0.0.1:9090/admin")
579            .body(axum::body::Body::empty())
580            .unwrap();
581        let response = proxy_handler(axum::extract::State(server), request).await.unwrap();
582
583        assert_eq!(response.status(), StatusCode::FORBIDDEN);
584    }
585
586    #[tokio::test]
587    async fn test_allowlisted_private_host_bypasses_guard() {
588        use crate::egress::{EgressDecision, EgressGuard, UpstreamAllowlist};
589
590        // Explicit operator opt-in re-opens SSRF by design; verify it wins.
591        let allowlist = UpstreamAllowlist {
592            url_prefixes: vec!["http://internal.local".to_string()],
593            hosts: Vec::new(),
594        };
595        let guard = EgressGuard::new(Some(allowlist));
596        match guard.check_without_dns("http://internal.local/v1") {
597            EgressDecision::Allowed => {}
598            EgressDecision::Blocked(e) => panic!("allowlisted host blocked: {}", e),
599        }
600    }
601
602    #[tokio::test]
603    async fn test_health_check() {
604        let response = health_check().await.unwrap();
605        assert_eq!(response.status(), StatusCode::OK);
606
607        // Response body is already a String
608        let body = response.into_body();
609
610        assert!(body.contains("healthy"));
611        assert!(body.contains("mockforge-proxy"));
612    }
613
614    #[tokio::test]
615    async fn test_proxy_stats() {
616        let config = ProxyConfig::default();
617        let server = ProxyServer::new(config, false, false);
618
619        // With zero requests, all derived stats should be zero
620        let stats = get_proxy_stats(&server).await;
621        assert_eq!(stats.total_requests, 0);
622        assert_eq!(stats.avg_response_time_ms, 0.0);
623        assert_eq!(stats.error_rate_percent, 0.0);
624    }
625}