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