Skip to main content

pjson_rs/infrastructure/http/
middleware.rs

1//! HTTP middleware for PJS optimization and monitoring
2
3use axum::{
4    extract::{ConnectInfo, Request},
5    http::{HeaderMap, HeaderValue, StatusCode, header},
6    middleware::Next,
7    response::Response,
8};
9use std::net::SocketAddr;
10use std::time::{Duration, Instant};
11use std::{
12    future::Future,
13    pin::Pin,
14    task::{Context, Poll},
15};
16use tower::{Layer, Service};
17
18/// Middleware for performance monitoring and optimization
19#[derive(Clone)]
20pub struct PjsMiddleware {
21    enable_compression: bool,
22    enable_metrics: bool,
23    max_request_size: usize,
24}
25
26impl PjsMiddleware {
27    /// Construct middleware with default settings (compression and metrics enabled, 10 MiB cap).
28    pub fn new() -> Self {
29        Self {
30            enable_compression: true,
31            enable_metrics: true,
32            max_request_size: 10 * 1024 * 1024, // 10MB
33        }
34    }
35
36    /// Toggle the `X-PJS-Compression` advertisement header.
37    pub fn with_compression(mut self, enabled: bool) -> Self {
38        self.enable_compression = enabled;
39        self
40    }
41
42    /// Toggle the `X-PJS-Duration-Ms` and `X-PJS-Version` response headers.
43    pub fn with_metrics(mut self, enabled: bool) -> Self {
44        self.enable_metrics = enabled;
45        self
46    }
47
48    /// Set the maximum allowed `Content-Length` for incoming requests, in bytes.
49    pub fn with_max_request_size(mut self, size: usize) -> Self {
50        self.max_request_size = size;
51        self
52    }
53}
54
55impl Default for PjsMiddleware {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61impl<S> Layer<S> for PjsMiddleware {
62    type Service = PjsMiddlewareService<S>;
63
64    fn layer(&self, inner: S) -> Self::Service {
65        PjsMiddlewareService {
66            inner,
67            config: self.clone(),
68        }
69    }
70}
71
72/// Tower service produced by [`PjsMiddleware`].
73#[derive(Clone)]
74pub struct PjsMiddlewareService<S> {
75    inner: S,
76    config: PjsMiddleware,
77}
78
79impl<S> Service<Request> for PjsMiddlewareService<S>
80where
81    S: Service<Request, Response = Response> + Clone + Send + 'static,
82    S::Future: Send + 'static,
83{
84    type Response = Response;
85    type Error = S::Error;
86    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
87
88    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
89        self.inner.poll_ready(cx)
90    }
91
92    fn call(&mut self, request: Request) -> Self::Future {
93        let mut inner = self.inner.clone();
94        let config = self.config.clone();
95
96        Box::pin(async move {
97            let start_time = Instant::now();
98
99            // Check request size
100            if let Some(content_length) = request.headers().get(header::CONTENT_LENGTH)
101                && let Ok(length_str) = content_length.to_str()
102                && let Ok(length) = length_str.parse::<usize>()
103                && length > config.max_request_size
104            {
105                return Ok(Response::builder()
106                    .status(StatusCode::PAYLOAD_TOO_LARGE)
107                    .body("Request too large".into())
108                    .map_err(|_| Response::new("Failed to build error response".into()))
109                    .unwrap_or_else(|err_response| err_response));
110            }
111
112            // Process request
113            let mut response = inner.call(request).await?;
114
115            // Add performance headers
116            if config.enable_metrics {
117                let duration = start_time.elapsed();
118                if let Ok(duration_value) = HeaderValue::from_str(&duration.as_millis().to_string())
119                {
120                    response
121                        .headers_mut()
122                        .insert("X-PJS-Duration-Ms", duration_value);
123                }
124
125                let version_value = HeaderValue::from_static(env!("CARGO_PKG_VERSION"));
126                response
127                    .headers_mut()
128                    .insert("X-PJS-Version", version_value);
129            }
130
131            // Add compression hints
132            if config.enable_compression {
133                response
134                    .headers_mut()
135                    .insert("X-PJS-Compression", HeaderValue::from_static("available"));
136            }
137
138            Ok(response)
139        })
140    }
141}
142
143/// Opt-in trusted-proxy allowlist for the HTTP rate limiter.
144///
145/// By default, [`RateLimitMiddleware`] keys rate limiting on the real TCP peer
146/// address and never trusts `X-Forwarded-For`/`X-Real-IP` — an unauthenticated
147/// client could otherwise send a fresh spoofed value on every request to get a
148/// fresh rate-limit bucket, fully bypassing the limiter. Set this only for
149/// deployments that sit behind a known reverse proxy or load balancer whose
150/// peer address(es) are listed here; requests from any other peer always use
151/// the real peer address regardless of these headers.
152///
153/// # Proxy contract
154///
155/// `X-Forwarded-For` is read right-to-left and takes precedence over
156/// `X-Real-IP` when both are present. The trusted proxy must *append* the
157/// address it saw the connection from to `X-Forwarded-For` rather than
158/// overwrite it (e.g. nginx's `$proxy_add_x_forwarded_for`, or any proxy that
159/// merges into a single header line rather than emitting a new one). Proxies
160/// that instead emit `<ip>:<port>` or bracketed IPv6 entries are not
161/// supported by this simple allowlist — the walk fails closed on the first
162/// unparseable entry (falls back to `X-Real-IP`, then the peer address)
163/// rather than skipping it and guessing from what remains. Repeated
164/// `X-Forwarded-For` header lines are read and treated as one comma-joined
165/// list in line order, per RFC 9110.
166#[derive(Debug, Clone, Default)]
167pub struct TrustedProxyConfig {
168    /// Peer addresses (the proxy's own TCP source address) allowed to supply
169    /// `X-Forwarded-For`/`X-Real-IP`.
170    pub trusted_proxies: Vec<std::net::IpAddr>,
171}
172
173impl TrustedProxyConfig {
174    /// Build a trusted-proxy config from an explicit allowlist of proxy addresses.
175    pub fn new(trusted_proxies: Vec<std::net::IpAddr>) -> Self {
176        Self { trusted_proxies }
177    }
178
179    /// Whether `ip` (already canonicalized via [`IpAddr::to_canonical`]) is in
180    /// the allowlist. Allowlist entries are canonicalized before comparison so
181    /// an IPv4 proxy configured as `10.0.0.1` still matches when it arrives as
182    /// the IPv4-mapped IPv6 address `::ffff:10.0.0.1` on a dual-stack listener.
183    fn contains(&self, ip: std::net::IpAddr) -> bool {
184        self.trusted_proxies.iter().any(|p| p.to_canonical() == ip)
185    }
186}
187
188/// Rate limiting configuration for HTTP endpoints
189#[derive(Debug, Clone)]
190pub struct RateLimitConfig {
191    /// Maximum requests per time window (default: 100)
192    pub max_requests_per_window: u32,
193    /// Time window duration (default: 60 seconds)
194    pub window_duration: std::time::Duration,
195    /// Opt-in trusted-proxy allowlist. `None` (the default) always keys the
196    /// rate limiter on the real TCP peer address. See [`TrustedProxyConfig`].
197    pub trusted_proxies: Option<TrustedProxyConfig>,
198}
199
200impl Default for RateLimitConfig {
201    fn default() -> Self {
202        Self {
203            max_requests_per_window: 100,
204            window_duration: std::time::Duration::from_secs(60),
205            trusted_proxies: None,
206        }
207    }
208}
209
210impl RateLimitConfig {
211    /// Build a per-minute rate limit (`requests_per_minute` requests per 60-second window).
212    pub fn new(requests_per_minute: u32) -> Self {
213        Self {
214            max_requests_per_window: requests_per_minute,
215            window_duration: std::time::Duration::from_secs(60),
216            trusted_proxies: None,
217        }
218    }
219
220    /// Override the window duration that `max_requests_per_window` applies to.
221    pub fn with_window(mut self, duration: std::time::Duration) -> Self {
222        self.window_duration = duration;
223        self
224    }
225
226    /// Opt in to trusting `X-Forwarded-For`/`X-Real-IP` from the given proxy allowlist.
227    pub fn with_trusted_proxies(mut self, config: TrustedProxyConfig) -> Self {
228        self.trusted_proxies = Some(config);
229        self
230    }
231}
232
233/// Rate limiting middleware for PJS endpoints
234///
235/// Uses token bucket algorithm from security::rate_limit module
236/// Returns 429 Too Many Requests when limit exceeded
237/// Adds X-RateLimit-* headers per RFC 6585
238#[derive(Clone)]
239pub struct RateLimitMiddleware {
240    limiter: std::sync::Arc<crate::security::rate_limit::WebSocketRateLimiter>,
241    trusted_proxies: Option<TrustedProxyConfig>,
242}
243
244impl RateLimitMiddleware {
245    /// Build a fresh middleware with its own internal `WebSocketRateLimiter`.
246    ///
247    /// Spawns a background task that periodically prunes expired per-IP
248    /// entries (see [`crate::security::rate_limit::WebSocketRateLimiter::spawn_cleanup_task`])
249    /// if called from within a Tokio runtime; otherwise construction still
250    /// succeeds, but periodic pruning is skipped with a logged warning.
251    pub fn new(config: RateLimitConfig) -> Self {
252        let trusted_proxies = config.trusted_proxies.clone();
253        let rate_limit_config = crate::security::rate_limit::RateLimitConfig {
254            max_requests_per_window: config.max_requests_per_window,
255            window_duration: config.window_duration,
256            ..Default::default()
257        };
258
259        let limiter = std::sync::Arc::new(crate::security::rate_limit::WebSocketRateLimiter::new(
260            rate_limit_config,
261        ));
262        limiter.spawn_cleanup_task(crate::security::rate_limit::DEFAULT_CLEANUP_INTERVAL);
263
264        Self {
265            limiter,
266            trusted_proxies,
267        }
268    }
269
270    /// Wrap an externally constructed `WebSocketRateLimiter` (lets several middlewares share state).
271    ///
272    /// Spawns a background cleanup task via
273    /// [`crate::security::rate_limit::WebSocketRateLimiter::spawn_cleanup_task`],
274    /// which is a no-op if one is already running for this limiter (e.g.
275    /// because another `RateLimitMiddleware` already wraps the same `Arc`).
276    pub fn from_limiter(
277        limiter: std::sync::Arc<crate::security::rate_limit::WebSocketRateLimiter>,
278    ) -> Self {
279        limiter.spawn_cleanup_task(crate::security::rate_limit::DEFAULT_CLEANUP_INTERVAL);
280
281        Self {
282            limiter,
283            trusted_proxies: None,
284        }
285    }
286
287    /// Opt in to trusting `X-Forwarded-For`/`X-Real-IP` from the given proxy allowlist.
288    pub fn with_trusted_proxies(mut self, config: TrustedProxyConfig) -> Self {
289        self.trusted_proxies = Some(config);
290        self
291    }
292}
293
294impl<S> Layer<S> for RateLimitMiddleware {
295    type Service = RateLimitService<S>;
296
297    fn layer(&self, inner: S) -> Self::Service {
298        RateLimitService {
299            inner,
300            limiter: self.limiter.clone(),
301            trusted_proxies: self.trusted_proxies.clone(),
302        }
303    }
304}
305
306/// Tower service produced by [`RateLimitMiddleware`].
307#[derive(Clone)]
308pub struct RateLimitService<S> {
309    inner: S,
310    limiter: std::sync::Arc<crate::security::rate_limit::WebSocketRateLimiter>,
311    trusted_proxies: Option<TrustedProxyConfig>,
312}
313
314impl<S> Service<Request> for RateLimitService<S>
315where
316    S: Service<Request, Response = Response> + Clone + Send + 'static,
317    S::Future: Send + 'static,
318{
319    type Response = Response;
320    type Error = S::Error;
321    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
322
323    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
324        self.inner.poll_ready(cx)
325    }
326
327    fn call(&mut self, request: Request) -> Self::Future {
328        let limiter = self.limiter.clone();
329        let trusted_proxies = self.trusted_proxies.clone();
330        let mut inner = self.inner.clone();
331
332        Box::pin(async move {
333            let client_ip = extract_client_ip(&request, trusted_proxies.as_ref());
334
335            // Check rate limit
336            match limiter.check_request(client_ip) {
337                Ok(()) => {
338                    // Rate limit passed - process request
339                    let response = inner.call(request).await?;
340
341                    // Add rate limit headers to response
342                    let mut response = response;
343                    add_rate_limit_headers(&mut response, &limiter, client_ip);
344
345                    Ok(response)
346                }
347                Err(err) => {
348                    let (status, retry_after, error_label) =
349                        rate_limit_error_response_parts(&err, &limiter, client_ip);
350
351                    let error_body = serde_json::json!({
352                        "error": error_label,
353                        "message": err.to_string(),
354                        "retry_after": retry_after
355                    })
356                    .to_string();
357
358                    let mut response = Response::builder()
359                        .status(status)
360                        .header(header::CONTENT_TYPE, "application/json")
361                        .header("Retry-After", retry_after.to_string())
362                        .body(error_body.into())
363                        .unwrap_or_else(|_| Response::new(error_label.into()));
364
365                    // A `CapacityExceeded` rejection has no per-client bucket
366                    // to describe — the IP was never admitted into the
367                    // tracked-client table — so the X-RateLimit-* quota
368                    // headers would misleadingly describe a bucket that
369                    // doesn't exist. Only attach them for per-client
370                    // rejections, where they're meaningful.
371                    if status != StatusCode::SERVICE_UNAVAILABLE {
372                        add_rate_limit_headers(&mut response, &limiter, client_ip);
373                    }
374
375                    Ok(response)
376                }
377            }
378        })
379    }
380}
381
382/// Map a [`crate::security::rate_limit::RateLimitError`] to the `(status,
383/// retry_after_secs, error_label)` triple used to build the 429/503 response.
384///
385/// A `CapacityExceeded` rejection is a server-side condition (the
386/// tracked-client table is full) rather than "you exceeded your own quota" —
387/// folding it into 429 would mislead the caller into backing off their own
388/// request rate, which does nothing to free capacity. It gets `503` instead,
389/// with a `Retry-After` tied to the cleanup sweep interval since that's the
390/// only thing that can free a slot. See `MAX_TRACKED_CLIENTS`'s docs in
391/// `security::rate_limit` for the accepted reject-new-clients tradeoff this
392/// implies under a sustained capacity attack.
393///
394/// Every other variant (in practice only `LimitExceeded`, the sole other
395/// variant [`WebSocketRateLimiter::check_request`] returns) gets `429` with a
396/// `Retry-After` derived from [`WebSocketRateLimiter::reset_after`] — the
397/// real per-client sliding-window expiry, consistent with the
398/// `X-RateLimit-Reset` header `add_rate_limit_headers` attaches to the same
399/// response.
400fn rate_limit_error_response_parts(
401    err: &crate::security::rate_limit::RateLimitError,
402    limiter: &crate::security::rate_limit::WebSocketRateLimiter,
403    client_ip: std::net::IpAddr,
404) -> (StatusCode, u64, &'static str) {
405    if matches!(
406        err,
407        crate::security::rate_limit::RateLimitError::CapacityExceeded { .. }
408    ) {
409        (
410            StatusCode::SERVICE_UNAVAILABLE,
411            crate::security::rate_limit::DEFAULT_CLEANUP_INTERVAL.as_secs(),
412            "Service Unavailable",
413        )
414    } else {
415        let retry_after = duration_secs_ceil(limiter.reset_after(client_ip)).max(1);
416        (
417            StatusCode::TOO_MANY_REQUESTS,
418            retry_after,
419            "Too Many Requests",
420        )
421    }
422}
423
424/// Rounds a [`Duration`] up to whole seconds, so any positive sub-second
425/// remainder still counts as a full extra second rather than being truncated
426/// away — a real (even if `<1s`) wait must never be reported as already
427/// elapsed (e.g. a sub-second `window_duration` would otherwise report a
428/// `Reset`/`Retry-After` of `0`, implying the quota already reset when it has
429/// not). `window_duration` is a `pub` field on a `Deserialize` config, so an
430/// operator-supplied value near `u64::MAX` seconds is reachable — `saturating_add`
431/// avoids a debug-panicking overflow on the `+1` ceiling adjustment.
432fn duration_secs_ceil(d: Duration) -> u64 {
433    d.as_secs().saturating_add(u64::from(d.subsec_nanos() > 0))
434}
435
436/// Extract the client IP address used as the rate-limit key.
437///
438/// Always trusts the real TCP peer address first, populated via axum's
439/// [`ConnectInfo`] extension — the router must be served with
440/// `into_make_service_with_connect_info::<SocketAddr>()`, otherwise no
441/// `ConnectInfo` extension is present, every client collapses onto a single
442/// shared bucket keyed on `127.0.0.1`, and a one-time warning is logged (see
443/// [`warn_missing_connect_info`]).
444///
445/// `X-Forwarded-For`/`X-Real-IP` are only consulted when `trusted_proxies` is
446/// set and the real peer address is in its allowlist. Trusting these headers
447/// unconditionally would let any client forge a fresh rate-limit bucket on
448/// every request, fully bypassing the limiter. Both the peer address and
449/// allowlist entries are compared via [`IpAddr::to_canonical`] so an IPv4
450/// proxy is still recognized when it arrives IPv4-mapped on a dual-stack
451/// listener.
452fn extract_client_ip(
453    request: &Request,
454    trusted_proxies: Option<&TrustedProxyConfig>,
455) -> std::net::IpAddr {
456    use std::net::{IpAddr, Ipv4Addr};
457
458    let Some(peer) = request
459        .extensions()
460        .get::<ConnectInfo<SocketAddr>>()
461        .map(|ConnectInfo(addr)| addr.ip().to_canonical())
462    else {
463        warn_missing_connect_info();
464        return IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
465    };
466
467    if let Some(proxies) = trusted_proxies
468        && proxies.contains(peer)
469        && let Some(forwarded_ip) = extract_forwarded_ip(request.headers(), proxies)
470    {
471        return forwarded_ip;
472    }
473
474    peer
475}
476
477/// Log once (per process) that a request arrived with no `ConnectInfo`
478/// extension, so misconfiguration is loud rather than silently collapsing
479/// every client onto one rate-limit bucket.
480fn warn_missing_connect_info() {
481    static WARNED: std::sync::Once = std::sync::Once::new();
482    WARNED.call_once(|| {
483        tracing::warn!(
484            "RateLimitMiddleware: request has no ConnectInfo<SocketAddr> extension; \
485             serve the router with `.into_make_service_with_connect_info::<SocketAddr>()` \
486             or every client will share a single rate-limit bucket keyed on 127.0.0.1 \
487             (logged once)"
488        );
489    });
490}
491
492/// Parse the client IP from `X-Forwarded-For` or `X-Real-IP`.
493///
494/// Only called for peers already verified against [`TrustedProxyConfig`] —
495/// these headers must never be trusted from an unverified peer.
496///
497/// `X-Forwarded-For` is walked right-to-left — across all header lines with
498/// that name, since `HeaderMap` allows repeats and they are semantically one
499/// comma-joined list in line order — skipping any entry that is itself a
500/// trusted proxy, and returns the first entry that is not. A well-behaved
501/// proxy *appends* the address it saw the connection from, so the rightmost
502/// non-trusted entry is the one appended by the closest trusted hop and
503/// cannot be forged by the client — taking the leftmost (client-supplied)
504/// entry instead would let a client behind a trusted proxy forge a fresh
505/// value on every request and reopen the exact bypass this module exists to
506/// close.
507///
508/// The walk **fails closed** on the first unparseable entry: it stops and
509/// falls through to `X-Real-IP` rather than skipping past the malformed
510/// entry into entries further left, which are progressively more
511/// attacker-controlled the further left they sit in the chain.
512fn extract_forwarded_ip(
513    headers: &HeaderMap,
514    trusted_proxies: &TrustedProxyConfig,
515) -> Option<std::net::IpAddr> {
516    let entries: Vec<&str> = headers
517        .get_all("x-forwarded-for")
518        .iter()
519        .filter_map(|h| h.to_str().ok())
520        .flat_map(|s| s.split(','))
521        .collect();
522
523    for entry in entries.into_iter().rev() {
524        let Ok(ip) = entry.trim().parse::<std::net::IpAddr>() else {
525            break;
526        };
527        let canonical = ip.to_canonical();
528        if !trusted_proxies.contains(canonical) {
529            return Some(canonical);
530        }
531    }
532
533    headers
534        .get("x-real-ip")
535        .and_then(|h| h.to_str().ok())
536        .and_then(|s| s.trim().parse::<std::net::IpAddr>().ok())
537        .map(|ip| ip.to_canonical())
538}
539
540/// Add X-RateLimit-* headers to response per RFC 6585
541fn add_rate_limit_headers(
542    response: &mut Response,
543    limiter: &crate::security::rate_limit::WebSocketRateLimiter,
544    client_ip: std::net::IpAddr,
545) {
546    use std::time::SystemTime;
547
548    let config = limiter.config();
549
550    response.headers_mut().insert(
551        "X-RateLimit-Limit",
552        HeaderValue::from(config.max_requests_per_window),
553    );
554
555    let remaining = limiter.remaining_for(client_ip);
556    response
557        .headers_mut()
558        .insert("X-RateLimit-Remaining", HeaderValue::from(remaining));
559
560    // The real per-client sliding-window expiry, not an approximation:
561    // `client.requests` is push-ordered ascending, so the oldest tracked
562    // request determines exactly when the next slot frees.
563    let reset_after_secs = duration_secs_ceil(limiter.reset_after(client_ip));
564    if let Some(reset_time) = SystemTime::now()
565        .duration_since(SystemTime::UNIX_EPOCH)
566        .ok()
567        .map(|d| d.as_secs().saturating_add(reset_after_secs))
568    {
569        response
570            .headers_mut()
571            .insert("X-RateLimit-Reset", HeaderValue::from(reset_time));
572    }
573}
574
575/// Connection upgrade middleware for WebSocket support
576pub async fn websocket_upgrade_middleware(
577    headers: HeaderMap,
578    request: Request,
579    next: Next,
580) -> Result<Response, StatusCode> {
581    // Check if this is a WebSocket upgrade request
582    if headers
583        .get(header::UPGRADE)
584        .and_then(|h| h.to_str().ok())
585        .map(|s| s.to_lowercase())
586        == Some("websocket".to_string())
587    {
588        // Handle WebSocket upgrade for PJS streaming
589        // This would integrate with the WebSocket handler
590        return handle_websocket_upgrade(request).await;
591    }
592
593    // Continue with regular HTTP handling
594    Ok(next.run(request).await)
595}
596
597/// Handle WebSocket upgrade for real-time PJS streaming
598async fn handle_websocket_upgrade(_request: Request) -> Result<Response, StatusCode> {
599    // Placeholder - would implement actual WebSocket upgrade logic
600    // using axum-websocket or similar
601    Response::builder()
602        .status(StatusCode::NOT_IMPLEMENTED)
603        .body("WebSocket support coming soon".into())
604        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
605}
606
607/// Compression middleware for reducing bandwidth
608pub async fn compression_middleware(headers: HeaderMap, request: Request, next: Next) -> Response {
609    let accepts_compression = headers
610        .get(header::ACCEPT_ENCODING)
611        .and_then(|h| h.to_str().ok())
612        .map(|s| s.contains("gzip") || s.contains("deflate"))
613        .unwrap_or(false);
614
615    let mut response = next.run(request).await;
616
617    // Add compression headers if client supports it
618    if accepts_compression {
619        response.headers_mut().insert(
620            "X-PJS-Compression-Available",
621            HeaderValue::from_static("gzip,deflate"),
622        );
623
624        // In production, would apply actual compression here
625        // using tower-http::compression::CompressionLayer
626    }
627
628    response
629}
630
631/// Security middleware for PJS endpoints
632pub async fn security_middleware(request: Request, next: Next) -> Response {
633    let mut response = next.run(request).await;
634
635    // Add security headers
636    let headers = response.headers_mut();
637    headers.insert(
638        "X-Content-Type-Options",
639        HeaderValue::from_static("nosniff"),
640    );
641    headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
642    headers.insert(
643        "Content-Security-Policy",
644        HeaderValue::from_static("default-src 'self'"),
645    );
646
647    response
648}
649
650/// Circuit breaker middleware for resilience
651#[derive(Clone)]
652pub struct CircuitBreakerMiddleware {
653    failure_threshold: usize,
654    recovery_timeout_seconds: u64,
655}
656
657impl CircuitBreakerMiddleware {
658    /// Build with default thresholds (5 failures, 30-second recovery).
659    pub fn new() -> Self {
660        Self {
661            failure_threshold: 5,
662            recovery_timeout_seconds: 30,
663        }
664    }
665
666    /// Override the consecutive-failure threshold that opens the circuit.
667    pub fn with_failure_threshold(mut self, threshold: usize) -> Self {
668        self.failure_threshold = threshold;
669        self
670    }
671
672    /// Override the recovery (cool-down) duration in seconds.
673    pub fn with_recovery_timeout(mut self, seconds: u64) -> Self {
674        self.recovery_timeout_seconds = seconds;
675        self
676    }
677}
678
679impl Default for CircuitBreakerMiddleware {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685/// Health check middleware that monitors PJS service health
686pub async fn health_check_middleware(request: Request, next: Next) -> Response {
687    // Add health metrics to response headers
688    let mut response = next.run(request).await;
689
690    // In production, would check actual service health
691    response
692        .headers_mut()
693        .insert("X-PJS-Health", HeaderValue::from_static("healthy"));
694
695    response
696}
697
698/// Content validation middleware configuration
699#[derive(Debug, Clone)]
700pub struct ContentValidationConfig {
701    /// Maximum allowed Content-Length in bytes (default: 10MB)
702    pub max_content_length: usize,
703
704    /// Allowed Content-Type values (default: application/json, application/pjs+json)
705    pub allowed_content_types: Vec<String>,
706
707    /// Require Content-Type header for POST/PUT/PATCH (default: true)
708    pub require_content_type: bool,
709}
710
711impl Default for ContentValidationConfig {
712    fn default() -> Self {
713        Self {
714            max_content_length: 10 * 1024 * 1024, // 10MB
715            allowed_content_types: vec![
716                "application/json".to_string(),
717                "application/pjs+json".to_string(),
718            ],
719            require_content_type: true,
720        }
721    }
722}
723
724/// Content validation middleware handler
725///
726/// Validates Content-Type and Content-Length headers to prevent:
727/// - Unsupported media types (415 error)
728/// - Oversized payloads (413 error)
729/// - DoS attacks via malformed headers
730pub async fn content_validation_middleware(
731    config: ContentValidationConfig,
732    req: Request,
733    next: Next,
734) -> Response {
735    // Extract method and headers
736    let method = req.method().clone();
737    let headers = req.headers();
738
739    // Validate Content-Length
740    if let Some(content_length_header) = headers.get(header::CONTENT_LENGTH) {
741        match content_length_header.to_str() {
742            Ok(content_length_str) => match content_length_str.parse::<usize>() {
743                Ok(content_length) => {
744                    if content_length > config.max_content_length {
745                        let error_body = serde_json::json!({
746                            "error": "Payload Too Large",
747                            "max_size": config.max_content_length,
748                            "received_size": content_length
749                        })
750                        .to_string();
751
752                        return Response::builder()
753                            .status(StatusCode::PAYLOAD_TOO_LARGE)
754                            .header(header::CONTENT_TYPE, "application/json")
755                            .body(error_body.into())
756                            .unwrap_or_else(|_| Response::new("Payload Too Large".into()));
757                    }
758                }
759                Err(_) => {
760                    let error_body = serde_json::json!({
761                        "error": "Invalid Content-Length header"
762                    })
763                    .to_string();
764
765                    return Response::builder()
766                        .status(StatusCode::BAD_REQUEST)
767                        .header(header::CONTENT_TYPE, "application/json")
768                        .body(error_body.into())
769                        .unwrap_or_else(|_| Response::new("Bad Request".into()));
770                }
771            },
772            Err(_) => {
773                let error_body = serde_json::json!({
774                    "error": "Invalid Content-Length header encoding"
775                })
776                .to_string();
777
778                return Response::builder()
779                    .status(StatusCode::BAD_REQUEST)
780                    .header(header::CONTENT_TYPE, "application/json")
781                    .body(error_body.into())
782                    .unwrap_or_else(|_| Response::new("Bad Request".into()));
783            }
784        }
785    }
786
787    // Validate Content-Type for POST/PUT/PATCH requests
788    if config.require_content_type && (method == "POST" || method == "PUT" || method == "PATCH") {
789        match headers.get(header::CONTENT_TYPE) {
790            Some(content_type_header) => {
791                let content_type = content_type_header.to_str().unwrap_or("");
792
793                // Extract base content type (ignore charset and other parameters)
794                let base_content_type = content_type.split(';').next().unwrap_or("").trim();
795
796                if !config
797                    .allowed_content_types
798                    .iter()
799                    .any(|allowed| base_content_type.eq_ignore_ascii_case(allowed))
800                {
801                    let error_body = serde_json::json!({
802                        "error": "Unsupported Media Type",
803                        "accepted": config.allowed_content_types,
804                        "received": content_type
805                    })
806                    .to_string();
807
808                    return Response::builder()
809                        .status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
810                        .header(header::CONTENT_TYPE, "application/json")
811                        .body(error_body.into())
812                        .unwrap_or_else(|_| Response::new("Unsupported Media Type".into()));
813                }
814            }
815            None => {
816                let error_body = serde_json::json!({
817                    "error": "Unsupported Media Type",
818                    "message": "Content-Type header is required for POST/PUT/PATCH requests",
819                    "accepted": config.allowed_content_types
820                })
821                .to_string();
822
823                return Response::builder()
824                    .status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
825                    .header(header::CONTENT_TYPE, "application/json")
826                    .body(error_body.into())
827                    .unwrap_or_else(|_| Response::new("Unsupported Media Type".into()));
828            }
829        }
830    }
831
832    // All validations passed, continue to next middleware/handler
833    next.run(req).await
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[tokio::test]
841    async fn test_pjs_middleware_creation() {
842        let middleware = PjsMiddleware::new()
843            .with_compression(true)
844            .with_metrics(true)
845            .with_max_request_size(5 * 1024 * 1024);
846
847        assert!(middleware.enable_compression);
848        assert!(middleware.enable_metrics);
849        assert_eq!(middleware.max_request_size, 5 * 1024 * 1024);
850    }
851
852    #[test]
853    fn test_rate_limit_config_default() {
854        let config = RateLimitConfig::default();
855        assert_eq!(config.max_requests_per_window, 100);
856        assert_eq!(config.window_duration, std::time::Duration::from_secs(60));
857    }
858
859    #[test]
860    fn test_rate_limit_config_new() {
861        let config = RateLimitConfig::new(50);
862        assert_eq!(config.max_requests_per_window, 50);
863    }
864
865    #[test]
866    fn test_rate_limit_config_with_window() {
867        let config = RateLimitConfig::new(100).with_window(std::time::Duration::from_secs(30));
868        assert_eq!(config.window_duration, std::time::Duration::from_secs(30));
869    }
870
871    #[tokio::test]
872    async fn test_rate_limit_middleware_creation() {
873        let config = RateLimitConfig::default();
874        let _middleware = RateLimitMiddleware::new(config);
875    }
876
877    #[tokio::test]
878    async fn test_from_limiter_claims_cleanup_spawn() {
879        // `RateLimitMiddleware::new`/`from_limiter` always spawn with the
880        // production `DEFAULT_CLEANUP_INTERVAL` (300s), which is too slow to
881        // wait out in a test — the underlying `spawn_cleanup_task` mechanism
882        // (real eviction after a short period, idempotency across repeated
883        // calls) is proven directly and quickly in `security::rate_limit`'s
884        // own tests. What this test proves instead, deterministically and
885        // fast, is that `from_limiter` actually calls it: `from_limiter` is
886        // the *first* caller here (not pre-empted by a manual
887        // `spawn_cleanup_task` call, which would make this a no-op check).
888        let limiter = std::sync::Arc::new(crate::security::rate_limit::WebSocketRateLimiter::new(
889            crate::security::rate_limit::RateLimitConfig::default(),
890        ));
891        assert!(!limiter.is_cleanup_task_spawned());
892
893        let _middleware = RateLimitMiddleware::from_limiter(limiter.clone());
894
895        assert!(
896            limiter.is_cleanup_task_spawned(),
897            "RateLimitMiddleware::from_limiter must wire up periodic cleanup"
898        );
899    }
900
901    #[tokio::test]
902    async fn test_new_claims_cleanup_spawn() {
903        let middleware = RateLimitMiddleware::new(RateLimitConfig::default());
904
905        assert!(
906            middleware.limiter.is_cleanup_task_spawned(),
907            "RateLimitMiddleware::new must wire up periodic cleanup"
908        );
909    }
910
911    #[test]
912    fn test_capacity_exceeded_maps_to_503_with_sweep_interval_retry_after() {
913        let err = crate::security::rate_limit::RateLimitError::CapacityExceeded {
914            max: crate::security::rate_limit::MAX_TRACKED_CLIENTS,
915        };
916        let limiter = crate::security::rate_limit::WebSocketRateLimiter::default();
917        let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1));
918        let (status, retry_after, label) = rate_limit_error_response_parts(&err, &limiter, ip);
919
920        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
921        assert_eq!(
922            retry_after,
923            crate::security::rate_limit::DEFAULT_CLEANUP_INTERVAL.as_secs()
924        );
925        assert_eq!(label, "Service Unavailable");
926    }
927
928    #[test]
929    fn test_other_rate_limit_errors_map_to_429_with_real_reset_derived_retry_after() {
930        let config = crate::security::rate_limit::RateLimitConfig {
931            max_requests_per_window: 1,
932            window_duration: std::time::Duration::from_secs(30),
933            ..Default::default()
934        };
935        let limiter = crate::security::rate_limit::WebSocketRateLimiter::new(config);
936        let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1));
937
938        limiter.check_request(ip).unwrap();
939        let err = limiter.check_request(ip).unwrap_err();
940
941        let (status, retry_after, label) = rate_limit_error_response_parts(&err, &limiter, ip);
942
943        assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
944        // The single prior request happened microseconds ago, so almost the
945        // entire 30s window remains — ceil-rounded up from just under 30s.
946        assert_eq!(retry_after, 30);
947        assert_eq!(label, "Too Many Requests");
948    }
949
950    #[test]
951    fn test_duration_secs_ceil_rounds_up_any_positive_remainder() {
952        assert_eq!(duration_secs_ceil(Duration::ZERO), 0);
953        assert_eq!(duration_secs_ceil(Duration::from_secs(1)), 1);
954        assert_eq!(duration_secs_ceil(Duration::from_millis(1)), 1);
955        assert_eq!(duration_secs_ceil(Duration::from_millis(500)), 1);
956        assert_eq!(duration_secs_ceil(Duration::from_millis(1500)), 2);
957        assert_eq!(duration_secs_ceil(Duration::from_nanos(1_000_000_001)), 2);
958    }
959
960    #[test]
961    fn test_duration_secs_ceil_saturates_instead_of_panicking_near_u64_max() {
962        // `Duration::MAX.as_secs() == u64::MAX` with a positive sub-second
963        // remainder, so a plain `+1` on the ceiling adjustment would
964        // debug-panic on overflow; `saturating_add` must not.
965        assert_eq!(duration_secs_ceil(Duration::MAX), u64::MAX);
966    }
967
968    #[test]
969    fn test_add_rate_limit_headers_never_panics_with_extreme_window() {
970        // `window_duration` is a `pub` field on a `Deserialize` config, so a
971        // near-`u64::MAX`-seconds value is reachable. `reset_after` then
972        // fails closed to the full window (see its doc), and the header's
973        // `now_unix_secs + reset_after_secs` must saturate rather than
974        // overflow-panic in debug.
975        let config = crate::security::rate_limit::RateLimitConfig {
976            window_duration: Duration::from_secs(u64::MAX),
977            ..Default::default()
978        };
979        let limiter = crate::security::rate_limit::WebSocketRateLimiter::new(config);
980        let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1));
981        limiter.check_request(ip).unwrap();
982
983        let mut response = Response::new(axum::body::Body::empty());
984        add_rate_limit_headers(&mut response, &limiter, ip); // Must not panic.
985
986        let reset = response
987            .headers()
988            .get("X-RateLimit-Reset")
989            .and_then(|v| v.to_str().ok())
990            .and_then(|s| s.parse::<u64>().ok())
991            .unwrap();
992        assert_eq!(reset, u64::MAX);
993    }
994
995    #[test]
996    fn test_rate_limit_error_response_parts_sub_second_window_never_reports_zero_retry_after() {
997        // A sub-second `window_duration` would make `reset_after` return a
998        // sub-second `Duration`; without ceil-rounding + the `.max(1)` floor,
999        // truncating that to whole seconds would report `Retry-After: 0`,
1000        // falsely implying the quota already reset when a real (if short)
1001        // wait remains.
1002        let config = crate::security::rate_limit::RateLimitConfig {
1003            max_requests_per_window: 1,
1004            window_duration: Duration::from_millis(200),
1005            ..Default::default()
1006        };
1007        let limiter = crate::security::rate_limit::WebSocketRateLimiter::new(config);
1008        let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1));
1009
1010        limiter.check_request(ip).unwrap();
1011        let err = limiter.check_request(ip).unwrap_err();
1012
1013        let (status, retry_after, _label) = rate_limit_error_response_parts(&err, &limiter, ip);
1014
1015        assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
1016        assert!(retry_after >= 1, "Retry-After must never be 0 on a 429");
1017    }
1018
1019    #[tokio::test]
1020    async fn test_from_limiter_on_already_spawned_limiter_does_not_reclaim() {
1021        // Simulates the scenario `from_limiter`'s docs describe: a limiter
1022        // whose cleanup was already spawned elsewhere (e.g. by
1023        // `SecureWebSocketHandler::new` sharing the same `Arc`). Wrapping it
1024        // again must observe the claim as already made, not attempt (or
1025        // need) a second spawn.
1026        let limiter = std::sync::Arc::new(crate::security::rate_limit::WebSocketRateLimiter::new(
1027            crate::security::rate_limit::RateLimitConfig::default(),
1028        ));
1029        limiter.spawn_cleanup_task(crate::security::rate_limit::DEFAULT_CLEANUP_INTERVAL);
1030        assert!(limiter.is_cleanup_task_spawned());
1031
1032        let _middleware = RateLimitMiddleware::from_limiter(limiter.clone());
1033
1034        assert!(limiter.is_cleanup_task_spawned());
1035    }
1036
1037    #[test]
1038    fn test_content_validation_config_default() {
1039        let config = ContentValidationConfig::default();
1040        assert_eq!(config.max_content_length, 10 * 1024 * 1024);
1041        assert_eq!(config.allowed_content_types.len(), 2);
1042        assert!(config.require_content_type);
1043    }
1044}