Skip to main content

wisegate_core/
request_handler.rs

1//! HTTP request handling and proxying.
2//!
3//! This module contains the core request handling logic for the reverse proxy,
4//! including IP validation, rate limiting, URL pattern blocking, and request forwarding.
5//!
6//! # Architecture
7//!
8//! The request handling flow:
9//! 1. Extract and validate client IP from proxy headers
10//! 2. Check if IP is blocked
11//! 3. Check for blocked URL patterns
12//! 4. Check for blocked HTTP methods
13//! 5. Apply rate limiting
14//! 6. Forward request to upstream service
15//!
16//! # Connection Pooling
17//!
18//! The module accepts a shared [`reqwest::Client`] for HTTP connection pooling,
19//! which should be configured by the caller with appropriate timeouts and with
20//! redirects disabled (`.redirect(reqwest::redirect::Policy::none())`):
21//! reqwest follows up to 10 redirects by default, which would serve targets
22//! that never went through wisegate's filters.
23
24use http_body_util::{BodyExt, Full};
25use hyper::{Request, Response, StatusCode, body::Incoming};
26use std::convert::Infallible;
27use std::net::SocketAddr;
28use std::sync::Arc;
29
30use crate::error::WiseGateError;
31use crate::types::{ConfigProvider, RateLimiter};
32use crate::{auth, headers, ip_filter, rate_limiter};
33
34/// Handles an incoming HTTP request through the proxy pipeline.
35///
36/// This is the main entry point for request processing. It performs:
37/// - Client IP extraction and validation from proxy headers
38/// - IP blocking checks
39/// - URL pattern blocking (e.g., `.php`, `.env` files)
40/// - HTTP method blocking (e.g., `PUT`, `DELETE`)
41/// - Rate limiting per client IP
42/// - Request forwarding to the upstream service
43///
44/// # Arguments
45///
46/// * `req` - The incoming HTTP request
47/// * `peer_addr` - Address of the TCP peer that sent the request
48/// * `forward_host` - The upstream host to forward requests to
49/// * `forward_port` - The upstream port to forward requests to
50/// * `limiter` - The shared rate limiter instance
51/// * `config` - Configuration provider for all settings
52/// * `http_client` - HTTP client for forwarding requests (with connection pooling)
53///
54/// # Returns
55///
56/// Always returns `Ok` with either:
57/// - A successful proxied response from upstream
58/// - An error response (403, 404, 405, 429, 502, etc.)
59///
60/// # Runtime
61///
62/// This is an async function backed by `reqwest`/`tokio`; it must be awaited
63/// from inside a Tokio runtime (`#[tokio::main]`, `Runtime::block_on`, etc.).
64///
65/// # Security
66///
67/// * **Strict mode** (proxy allowlist configured): the TCP peer must be in
68///   the allowlist (or be a loopback address when
69///   [`crate::ProxyProvider::trust_loopback_proxy`] is enabled). Only the last
70///   element of the `Forwarded` header, the one that proxy appended, is used:
71///   its `by=` must be in the allowlist and its `for=` is the client IP.
72///   `X-Forwarded-For` is ignored. Requests failing any of these checks are
73///   rejected with `400 Bad Request`.
74/// * **Permissive mode** (no allowlist): if a request supplies an
75///   `X-Forwarded-For` or `Forwarded` header, the parsed IP is trusted as the
76///   real client IP and re-emitted to the upstream as `X-Real-IP`. An
77///   attacker can spoof this value by sending fake headers — only enable
78///   permissive mode when the proxy sits behind another layer that strips or
79///   normalises these headers.
80/// * Any client-supplied `X-Real-IP` header is stripped before processing.
81/// * The `Authorization` header is stripped before forwarding whenever
82///   wisegate performed authentication (see
83///   [`crate::AuthenticationProvider::forward_authorization_header`]).
84pub async fn handle_request<C: ConfigProvider>(
85    req: Request<Incoming>,
86    peer_addr: SocketAddr,
87    forward_host: Arc<str>,
88    forward_port: u16,
89    limiter: RateLimiter,
90    config: Arc<C>,
91    http_client: reqwest::Client,
92) -> Result<Response<Full<bytes::Bytes>>, Infallible> {
93    // Extract and validate real client IP
94    let real_client_ip: Option<String> = match ip_filter::extract_and_validate_real_ip(
95        req.headers(),
96        peer_addr.ip(),
97        config.as_ref(),
98    ) {
99        Some(ip) => Some(ip),
100        None => {
101            if config.allowed_proxy_ips().is_none() {
102                // Permissive mode: continue without IP-based security
103                None
104            } else {
105                // Strict mode: reject when proxy validation fails
106                let err = WiseGateError::InvalidIp("missing or invalid proxy headers".into());
107                return Ok(create_error_response(err.status_code(), err.user_message()));
108            }
109        }
110    };
111
112    // Check if IP is blocked
113    if let Some(ref ip) = real_client_ip
114        && ip_filter::is_ip_blocked(ip, config.as_ref())
115    {
116        let err = WiseGateError::IpBlocked(ip.clone());
117        return Ok(create_error_response(err.status_code(), err.user_message()));
118    }
119
120    // Check for blocked URL patterns
121    let request_path = req.uri().path();
122    if is_url_pattern_blocked(request_path, config.as_ref()) {
123        let err = WiseGateError::PatternBlocked(request_path.to_string());
124        return Ok(create_error_response(err.status_code(), err.user_message()));
125    }
126
127    // Check for blocked HTTP methods
128    let request_method = req.method().as_str();
129    if is_method_blocked(request_method, config.as_ref()) {
130        let err = WiseGateError::MethodBlocked(request_method.to_string());
131        return Ok(create_error_response(err.status_code(), err.user_message()));
132    }
133
134    // Count attempts before authentication so failed credentials cannot bypass
135    // the per-IP budget. Successful requests are charged exactly once.
136    if let Some(ref ip) = real_client_ip
137        && !rate_limiter::check_rate_limit(&limiter, ip, config.as_ref()).await
138    {
139        let err = WiseGateError::RateLimitExceeded(ip.clone());
140        return Ok(create_error_response(err.status_code(), err.user_message()));
141    }
142
143    // Check Authentication if enabled (Basic Auth and/or Bearer Token)
144    // Logic: if both are configured, either one passing is sufficient
145    if config.is_auth_enabled() {
146        let auth_header = req
147            .headers()
148            .get(headers::AUTHORIZATION)
149            .and_then(|v| v.to_str().ok());
150
151        let basic_auth_enabled = config.is_basic_auth_enabled();
152        let bearer_auth_enabled = config.is_bearer_auth_enabled();
153
154        let basic_auth_passed = if basic_auth_enabled {
155            match auth::check_basic_auth_bounded(auth_header, config.auth_credentials()).await {
156                Ok(passed) => passed,
157                Err(_) => {
158                    return Ok(create_error_response(
159                        StatusCode::SERVICE_UNAVAILABLE,
160                        "Authentication temporarily unavailable",
161                    ));
162                }
163            }
164        } else {
165            false
166        };
167        let bearer_auth_passed =
168            bearer_auth_enabled && auth::check_bearer_token(auth_header, config.bearer_token());
169
170        // Authentication fails if neither method passed
171        if !basic_auth_passed && !bearer_auth_passed {
172            return Ok(create_unauthorized_response(config.auth_realm()));
173        }
174    }
175
176    // Forward the request. Any client-supplied X-Real-IP is dropped and the
177    // validated one (if any) is added after hop-by-hop filtering, so upstream
178    // sees only wisegate's value.
179    forward_request(
180        req,
181        real_client_ip.as_deref(),
182        &forward_host,
183        forward_port,
184        config.as_ref(),
185        &http_client,
186    )
187    .await
188}
189
190/// Forward request to upstream service
191async fn forward_request(
192    req: Request<Incoming>,
193    real_client_ip: Option<&str>,
194    host: &str,
195    port: u16,
196    config: &impl ConfigProvider,
197    http_client: &reqwest::Client,
198) -> Result<Response<Full<bytes::Bytes>>, Infallible> {
199    let proxy_config = config.proxy_config();
200    let (parts, body) = req.into_parts();
201
202    // Early rejection based on Content-Length header to prevent memory exhaustion
203    if proxy_config.max_body_size > 0
204        && let Some(content_length) = parts
205            .headers
206            .get(headers::CONTENT_LENGTH)
207            .and_then(|v| v.to_str().ok())
208            .and_then(|v| v.parse::<usize>().ok())
209        && content_length > proxy_config.max_body_size
210    {
211        let err = WiseGateError::BodyTooLarge {
212            size: content_length,
213            max: proxy_config.max_body_size,
214        };
215        return Ok(create_body_error_response(&err));
216    }
217
218    let body_bytes = match read_request_body(
219        body,
220        proxy_config.max_body_size,
221        config.client_body_timeout(),
222    )
223    .await
224    {
225        Ok(bytes) => bytes,
226        Err(err) => {
227            return Ok(create_body_error_response(&err));
228        }
229    };
230
231    let strip_auth = config.is_auth_enabled() && !config.forward_authorization_header();
232    forward_with_reqwest(
233        parts,
234        body_bytes,
235        real_client_ip,
236        host,
237        port,
238        http_client,
239        strip_auth,
240    )
241    .await
242}
243
244async fn read_request_body<B>(
245    body: B,
246    max_size: usize,
247    timeout: std::time::Duration,
248) -> Result<bytes::Bytes, WiseGateError>
249where
250    B: hyper::body::Body<Data = bytes::Bytes> + Unpin,
251    B::Error: std::fmt::Display,
252{
253    tokio::time::timeout(timeout, collect_request_body(body, max_size))
254        .await
255        .map_err(|_| WiseGateError::ClientBodyTimeout)?
256}
257
258fn create_body_error_response(err: &WiseGateError) -> Response<Full<bytes::Bytes>> {
259    let mut response = create_error_response(err.status_code(), err.user_message());
260    // The unread body must not be reused as a subsequent HTTP/1 request.
261    response.headers_mut().insert(
262        headers::CONNECTION,
263        hyper::header::HeaderValue::from_static("close"),
264    );
265    response
266}
267
268/// Enforce the limit as frames arrive, including when no Content-Length exists.
269/// Reject the first oversized frame before buffering it or waiting for EOF.
270async fn collect_request_body<B>(
271    mut body: B,
272    max_size: usize,
273) -> Result<bytes::Bytes, WiseGateError>
274where
275    B: hyper::body::Body<Data = bytes::Bytes> + Unpin,
276    B::Error: std::fmt::Display,
277{
278    let mut buffer = bytes::BytesMut::new();
279    while let Some(frame) = body.frame().await {
280        let frame = frame.map_err(|err| WiseGateError::BodyReadError(err.to_string()))?;
281        if let Ok(data) = frame.into_data() {
282            let size = buffer.len().saturating_add(data.len());
283            if max_size > 0 && size > max_size {
284                return Err(WiseGateError::BodyTooLarge {
285                    size,
286                    max: max_size,
287                });
288            }
289            buffer.extend_from_slice(&data);
290        }
291    }
292    Ok(buffer.freeze())
293}
294
295/// Shared forwarding logic using reqwest with connection pooling
296async fn forward_with_reqwest(
297    parts: hyper::http::request::Parts,
298    body_bytes: bytes::Bytes,
299    real_client_ip: Option<&str>,
300    host: &str,
301    port: u16,
302    client: &reqwest::Client,
303    strip_auth: bool,
304) -> Result<Response<Full<bytes::Bytes>>, Infallible> {
305    // Construct destination URI
306    let destination_uri = format!(
307        "http://{}:{}{}",
308        host,
309        port,
310        parts.uri.path_and_query().map_or("", |pq| pq.as_str())
311    );
312
313    // Build the request with the original HTTP method
314    let method = match reqwest::Method::from_bytes(parts.method.as_str().as_bytes()) {
315        Ok(m) => m,
316        Err(_) => {
317            let err =
318                WiseGateError::MethodBlocked(format!("{} (unsupported)", parts.method.as_str()));
319            return Ok(create_error_response(err.status_code(), err.user_message()));
320        }
321    };
322    let mut req_builder = client.request(method, &destination_uri);
323
324    // Add headers (excluding host, content-length, and hop-by-hop headers per
325    // RFC 9110 section 7.6.1, including those nominated by `Connection`).
326    // When `strip_auth` is set, also drop Authorization so the upstream cannot
327    // re-validate or log credentials wisegate just consumed.
328    let nominated = headers::connection_nominated(&parts.headers);
329    for (name, value) in parts.headers.iter() {
330        if name != headers::HOST
331            && name != headers::CONTENT_LENGTH
332            && name != headers::X_REAL_IP
333            && !(strip_auth && name == headers::AUTHORIZATION)
334            && !headers::is_hop_by_hop_for(name.as_str(), &nominated)
335            && let Ok(header_value) = value.to_str()
336        {
337            req_builder = req_builder.header(name.as_str(), header_value);
338        }
339    }
340    if let Some(ip) = real_client_ip {
341        req_builder = req_builder.header(headers::X_REAL_IP, ip);
342    }
343
344    // Add body if not empty
345    if !body_bytes.is_empty() {
346        req_builder = req_builder.body(body_bytes);
347    }
348
349    // Send request
350    match req_builder.send().await {
351        Ok(response) => {
352            let status = response.status();
353            let resp_headers = response.headers().clone();
354
355            match response.bytes().await {
356                Ok(body_bytes) => {
357                    let mut hyper_response = match Response::builder()
358                        .status(status.as_u16())
359                        .body(Full::new(body_bytes))
360                    {
361                        Ok(resp) => resp,
362                        Err(e) => {
363                            let err = WiseGateError::ProxyError(format!(
364                                "Failed to build response: {}",
365                                e
366                            ));
367                            return Ok(create_error_response(
368                                err.status_code(),
369                                err.user_message(),
370                            ));
371                        }
372                    };
373
374                    // Copy response headers, skipping hop-by-hop ones. `append`
375                    // keeps every value of repeated fields such as Set-Cookie.
376                    let nominated = headers::connection_nominated(&resp_headers);
377                    for (name, value) in resp_headers.iter() {
378                        if !headers::is_hop_by_hop_for(name.as_str(), &nominated)
379                            && let (Ok(hyper_name), Ok(hyper_value)) = (
380                                hyper::header::HeaderName::from_bytes(name.as_str().as_bytes()),
381                                hyper::header::HeaderValue::from_bytes(value.as_bytes()),
382                            )
383                        {
384                            hyper_response.headers_mut().append(hyper_name, hyper_value);
385                        }
386                    }
387
388                    Ok(hyper_response)
389                }
390                Err(e) => {
391                    let err = WiseGateError::BodyReadError(format!("response: {}", e));
392                    Ok(create_error_response(err.status_code(), err.user_message()))
393                }
394            }
395        }
396        Err(err) => {
397            // More specific error handling using WiseGateError
398            let wise_err = if err.is_timeout() {
399                WiseGateError::UpstreamTimeout(err.to_string())
400            } else if err.is_connect() {
401                WiseGateError::UpstreamConnectionFailed(err.to_string())
402            } else {
403                WiseGateError::ProxyError(err.to_string())
404            };
405            Ok(create_error_response(
406                wise_err.status_code(),
407                wise_err.user_message(),
408            ))
409        }
410    }
411}
412
413/// Creates a standardized error response.
414///
415/// Builds an HTTP response with the given status code and plain text message.
416/// Falls back to a minimal 500 response if building fails (should never happen
417/// with valid StatusCode).
418///
419/// # Arguments
420///
421/// * `status` - The HTTP status code for the response
422/// * `message` - The plain text error message body
423///
424/// # Returns
425///
426/// An HTTP response with `content-type: text/plain` header.
427///
428/// # Example
429///
430/// ```
431/// use wisegate_core::request_handler::create_error_response;
432/// use hyper::StatusCode;
433///
434/// let response = create_error_response(StatusCode::NOT_FOUND, "Resource not found");
435/// assert_eq!(response.status(), StatusCode::NOT_FOUND);
436/// ```
437pub fn create_error_response(status: StatusCode, message: &str) -> Response<Full<bytes::Bytes>> {
438    Response::builder()
439        .status(status)
440        .header(headers::CONTENT_TYPE, "text/plain")
441        .body(Full::new(bytes::Bytes::from(message.to_string())))
442        .unwrap_or_else(|_| {
443            // Fallback response if builder fails (extremely unlikely)
444            Response::new(Full::new(bytes::Bytes::from("Internal Server Error")))
445        })
446}
447
448/// Creates a 401 Unauthorized response with WWW-Authenticate header.
449///
450/// Used when Basic Authentication is enabled and the request is not authenticated
451/// or has invalid credentials.
452///
453/// # Arguments
454///
455/// * `realm` - The authentication realm to display in the browser dialog
456///
457/// # Returns
458///
459/// An HTTP 401 response with `WWW-Authenticate: Basic realm="..."` header.
460pub fn create_unauthorized_response(realm: &str) -> Response<Full<bytes::Bytes>> {
461    // Sanitize realm: escape backslashes and quotes per RFC 7235 quoted-string
462    let sanitized_realm = realm.replace('\\', "\\\\").replace('"', "\\\"");
463    Response::builder()
464        .status(StatusCode::UNAUTHORIZED)
465        .header(
466            headers::WWW_AUTHENTICATE,
467            format!("Basic realm=\"{}\"", sanitized_realm),
468        )
469        .header(headers::CONTENT_TYPE, "text/plain")
470        .body(Full::new(bytes::Bytes::from("401 Unauthorized")))
471        .unwrap_or_else(|_| Response::new(Full::new(bytes::Bytes::from("401 Unauthorized"))))
472}
473
474/// Check if URL path contains any blocked patterns
475/// Decodes URL-encoded characters to prevent bypass via encoding (e.g., .ph%70 for .php)
476fn is_url_pattern_blocked(path: &str, config: &impl ConfigProvider) -> bool {
477    let blocked_patterns = config.blocked_patterns();
478    if blocked_patterns.is_empty() {
479        return false;
480    }
481
482    // Decode URL-encoded path to prevent bypass attacks
483    let decoded_path = url_decode(path);
484    let has_encoding = decoded_path != path;
485
486    // Case-insensitive matching to prevent bypass via case variation
487    let path_lower = path.to_lowercase();
488    // Only allocate decoded lowercase if URL actually contained percent-encoding
489    let decoded_lower = if has_encoding {
490        Some(decoded_path.to_lowercase())
491    } else {
492        None
493    };
494
495    // Patterns are expected to be pre-normalized to lowercase
496    blocked_patterns.iter().any(|pattern| {
497        path_lower.contains(pattern.as_str())
498            || decoded_lower
499                .as_ref()
500                .is_some_and(|dl| dl.contains(pattern.as_str()))
501    })
502}
503
504/// Decode URL-encoded string (percent-encoding)
505/// Handles common bypass attempts like %2e for '.', %70 for 'p', etc.
506/// Properly handles multi-byte UTF-8 sequences.
507fn url_decode(input: &str) -> String {
508    let mut bytes = Vec::with_capacity(input.len());
509    let input_bytes = input.as_bytes();
510    let mut i = 0;
511
512    while i < input_bytes.len() {
513        if input_bytes[i] == b'%' && i + 2 < input_bytes.len() {
514            // Try to decode two hex digits without allocating
515            let hi = hex_digit(input_bytes[i + 1]);
516            let lo = hex_digit(input_bytes[i + 2]);
517            if let (Some(h), Some(l)) = (hi, lo) {
518                bytes.push(h << 4 | l);
519                i += 3;
520                continue;
521            }
522        }
523        bytes.push(input_bytes[i]);
524        i += 1;
525    }
526
527    // Convert bytes to string, replacing invalid UTF-8 with replacement character
528    String::from_utf8_lossy(&bytes).into_owned()
529}
530
531/// Converts an ASCII hex digit to its numeric value.
532fn hex_digit(b: u8) -> Option<u8> {
533    match b {
534        b'0'..=b'9' => Some(b - b'0'),
535        b'a'..=b'f' => Some(b - b'a' + 10),
536        b'A'..=b'F' => Some(b - b'A' + 10),
537        _ => None,
538    }
539}
540
541/// Check if HTTP method is blocked
542fn is_method_blocked(method: &str, config: &impl ConfigProvider) -> bool {
543    let blocked_methods = config.blocked_methods();
544    blocked_methods
545        .iter()
546        .any(|blocked_method| blocked_method.eq_ignore_ascii_case(method))
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::test_utils::TestConfig;
553    use http_body_util::BodyExt;
554
555    #[tokio::test]
556    async fn body_limit_rejects_chunked_stream_without_waiting_for_eof() {
557        use futures::{FutureExt, StreamExt, stream};
558        use hyper::body::Frame;
559
560        let frames = stream::iter([
561            Ok::<_, Infallible>(Frame::data(bytes::Bytes::from_static(b"abc"))),
562            Ok(Frame::data(bytes::Bytes::from_static(b"def"))),
563        ])
564        .chain(stream::pending());
565        let body = http_body_util::StreamBody::new(frames);
566        let result = collect_request_body(body, 5)
567            .now_or_never()
568            .expect("must reject before the stream ends");
569        assert!(matches!(
570            result,
571            Err(WiseGateError::BodyTooLarge { size: 6, max: 5 })
572        ));
573    }
574
575    #[tokio::test]
576    async fn body_limit_accepts_exact_limit_and_zero_remains_unlimited() {
577        for limit in [0, 6] {
578            let result =
579                collect_request_body(Full::new(bytes::Bytes::from_static(b"abcdef")), limit)
580                    .await
581                    .unwrap();
582            assert_eq!(result.as_ref(), b"abcdef");
583        }
584    }
585
586    #[tokio::test]
587    async fn unfinished_body_times_out_and_closes_connection() {
588        use futures::stream;
589        use hyper::body::Frame;
590
591        let stream = stream::pending::<Result<Frame<bytes::Bytes>, Infallible>>();
592        let err = read_request_body(
593            http_body_util::StreamBody::new(stream),
594            0,
595            std::time::Duration::from_millis(1),
596        )
597        .await
598        .unwrap_err();
599        let response = create_body_error_response(&err);
600        assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
601        assert_eq!(response.headers()[headers::CONNECTION], "close");
602        assert!(!err.is_server_error());
603    }
604
605    // ===========================================
606    // url_decode tests
607    // ===========================================
608
609    #[test]
610    fn test_url_decode_no_encoding() {
611        assert_eq!(url_decode("/path/to/file"), "/path/to/file");
612        assert_eq!(url_decode("hello"), "hello");
613        assert_eq!(url_decode(""), "");
614    }
615
616    #[test]
617    fn test_url_decode_simple_encoding() {
618        assert_eq!(url_decode("%20"), " ");
619        assert_eq!(url_decode("hello%20world"), "hello world");
620        assert_eq!(url_decode("%2F"), "/");
621    }
622
623    #[test]
624    fn test_url_decode_dot_encoding() {
625        // Common bypass attempts
626        assert_eq!(url_decode("%2e"), ".");
627        assert_eq!(url_decode("%2E"), ".");
628        assert_eq!(url_decode(".%2ephp"), "..php");
629    }
630
631    #[test]
632    fn test_url_decode_php_bypass() {
633        // Attacker tries to bypass .php blocking
634        assert_eq!(url_decode(".ph%70"), ".php");
635        assert_eq!(url_decode("%2ephp"), ".php");
636        assert_eq!(url_decode(".%70%68%70"), ".php");
637    }
638
639    #[test]
640    fn test_url_decode_env_bypass() {
641        // Attacker tries to bypass .env blocking
642        assert_eq!(url_decode(".%65nv"), ".env");
643        assert_eq!(url_decode("%2eenv"), ".env");
644        assert_eq!(url_decode("%2e%65%6e%76"), ".env");
645    }
646
647    #[test]
648    fn test_url_decode_multiple_encodings() {
649        assert_eq!(url_decode("%2F%2e%2e%2Fetc%2Fpasswd"), "/../etc/passwd");
650    }
651
652    #[test]
653    fn test_url_decode_invalid_hex() {
654        // Invalid hex should be preserved
655        assert_eq!(url_decode("%GG"), "%GG");
656        assert_eq!(url_decode("%"), "%");
657        assert_eq!(url_decode("%2"), "%2");
658        assert_eq!(url_decode("%ZZ"), "%ZZ");
659    }
660
661    #[test]
662    fn test_url_decode_mixed_content() {
663        assert_eq!(url_decode("path%2Fto%2Ffile.txt"), "path/to/file.txt");
664        assert_eq!(url_decode("hello%20%26%20world"), "hello & world");
665    }
666
667    #[test]
668    fn test_url_decode_unicode() {
669        // UTF-8 encoded characters
670        assert_eq!(url_decode("%C3%A9"), "é"); // é in UTF-8
671        assert_eq!(url_decode("caf%C3%A9"), "café");
672    }
673
674    // ===========================================
675    // is_url_pattern_blocked tests
676    // ===========================================
677
678    #[test]
679    fn test_url_pattern_blocked_simple() {
680        let config = TestConfig::new().with_blocked_patterns(vec![".php", ".env"]);
681
682        assert!(is_url_pattern_blocked("/file.php", &config));
683        assert!(is_url_pattern_blocked("/.env", &config));
684        assert!(is_url_pattern_blocked("/path/to/file.php", &config));
685    }
686
687    #[test]
688    fn test_url_pattern_not_blocked() {
689        let config = TestConfig::new().with_blocked_patterns(vec![".php", ".env"]);
690
691        assert!(!is_url_pattern_blocked("/file.html", &config));
692        assert!(!is_url_pattern_blocked("/path/to/file.js", &config));
693        assert!(!is_url_pattern_blocked("/", &config));
694    }
695
696    #[test]
697    fn test_url_pattern_blocked_empty_patterns() {
698        let config = TestConfig::new();
699
700        assert!(!is_url_pattern_blocked("/file.php", &config));
701        assert!(!is_url_pattern_blocked("/.env", &config));
702    }
703
704    #[test]
705    fn test_url_pattern_blocked_bypass_attempt() {
706        let config = TestConfig::new().with_blocked_patterns(vec![".php", ".env", "admin"]);
707
708        // URL-encoded bypass attempts should still be blocked
709        assert!(is_url_pattern_blocked("/.ph%70", &config)); // .php
710        assert!(is_url_pattern_blocked("/%2eenv", &config)); // .env
711        assert!(is_url_pattern_blocked("/adm%69n", &config)); // admin
712    }
713
714    #[test]
715    fn test_url_pattern_blocked_double_encoding_attempt() {
716        let config = TestConfig::new().with_blocked_patterns(vec![".php"]);
717
718        // Single encoding should be caught
719        assert!(is_url_pattern_blocked("/.ph%70", &config));
720    }
721
722    #[test]
723    fn test_url_pattern_blocked_case_insensitive() {
724        let config = TestConfig::new().with_blocked_patterns(vec![".php"]);
725
726        // Pattern matching is case-insensitive to prevent bypass
727        assert!(is_url_pattern_blocked("/file.PHP", &config));
728        assert!(is_url_pattern_blocked("/file.php", &config));
729        assert!(is_url_pattern_blocked("/file.Php", &config));
730    }
731
732    #[test]
733    fn test_url_pattern_blocked_partial_match() {
734        let config = TestConfig::new().with_blocked_patterns(vec!["admin"]);
735
736        assert!(is_url_pattern_blocked("/admin/panel", &config));
737        assert!(is_url_pattern_blocked("/path/admin", &config));
738        assert!(is_url_pattern_blocked("/administrator", &config)); // Contains "admin"
739    }
740
741    // ===========================================
742    // is_method_blocked tests
743    // ===========================================
744
745    #[test]
746    fn test_method_blocked() {
747        let config = TestConfig::new().with_blocked_methods(vec!["TRACE", "CONNECT"]);
748
749        assert!(is_method_blocked("TRACE", &config));
750        assert!(is_method_blocked("CONNECT", &config));
751    }
752
753    #[test]
754    fn test_method_not_blocked() {
755        let config = TestConfig::new().with_blocked_methods(vec!["TRACE", "CONNECT"]);
756
757        assert!(!is_method_blocked("GET", &config));
758        assert!(!is_method_blocked("POST", &config));
759        assert!(!is_method_blocked("PUT", &config));
760        assert!(!is_method_blocked("DELETE", &config));
761    }
762
763    #[test]
764    fn test_method_blocked_empty_list() {
765        let config = TestConfig::new();
766
767        assert!(!is_method_blocked("TRACE", &config));
768        assert!(!is_method_blocked("GET", &config));
769    }
770
771    #[test]
772    fn test_method_blocked_case_insensitive() {
773        let config = TestConfig::new().with_blocked_methods(vec!["TRACE"]);
774
775        assert!(is_method_blocked("TRACE", &config));
776        assert!(is_method_blocked("trace", &config));
777        assert!(is_method_blocked("Trace", &config));
778    }
779
780    // ===========================================
781    // create_error_response tests
782    // ===========================================
783
784    #[test]
785    fn test_create_error_response_status() {
786        let response = create_error_response(StatusCode::NOT_FOUND, "Not Found");
787        assert_eq!(response.status(), StatusCode::NOT_FOUND);
788
789        let response = create_error_response(StatusCode::FORBIDDEN, "Forbidden");
790        assert_eq!(response.status(), StatusCode::FORBIDDEN);
791
792        let response = create_error_response(StatusCode::TOO_MANY_REQUESTS, "Rate limited");
793        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
794    }
795
796    #[test]
797    fn test_create_error_response_content_type() {
798        let response = create_error_response(StatusCode::NOT_FOUND, "Not Found");
799        assert_eq!(
800            response.headers().get("content-type").unwrap(),
801            "text/plain"
802        );
803    }
804
805    #[tokio::test]
806    async fn test_create_error_response_body() {
807        let response = create_error_response(StatusCode::NOT_FOUND, "Resource not found");
808        let body = response.into_body().collect().await.unwrap().to_bytes();
809        assert_eq!(body, "Resource not found");
810    }
811
812    #[tokio::test]
813    async fn test_create_error_response_empty_message() {
814        let response = create_error_response(StatusCode::NO_CONTENT, "");
815        let body = response.into_body().collect().await.unwrap().to_bytes();
816        assert_eq!(body, "");
817    }
818
819    // ===========================================
820    // create_unauthorized_response tests
821    // ===========================================
822
823    #[test]
824    fn test_unauthorized_response_status() {
825        let response = create_unauthorized_response("WiseGate");
826        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
827    }
828
829    #[test]
830    fn test_unauthorized_response_www_authenticate_header() {
831        let response = create_unauthorized_response("WiseGate");
832        let header = response
833            .headers()
834            .get("www-authenticate")
835            .unwrap()
836            .to_str()
837            .unwrap();
838        assert_eq!(header, "Basic realm=\"WiseGate\"");
839    }
840
841    #[test]
842    fn test_unauthorized_response_realm_with_quotes() {
843        let response = create_unauthorized_response("My \"Realm\"");
844        let header = response
845            .headers()
846            .get("www-authenticate")
847            .unwrap()
848            .to_str()
849            .unwrap();
850        assert_eq!(header, "Basic realm=\"My \\\"Realm\\\"\"");
851    }
852
853    #[test]
854    fn test_unauthorized_response_realm_with_backslash() {
855        let response = create_unauthorized_response("My\\Realm");
856        let header = response
857            .headers()
858            .get("www-authenticate")
859            .unwrap()
860            .to_str()
861            .unwrap();
862        assert_eq!(header, "Basic realm=\"My\\\\Realm\"");
863    }
864
865    #[test]
866    fn test_unauthorized_response_content_type() {
867        let response = create_unauthorized_response("WiseGate");
868        assert_eq!(
869            response.headers().get("content-type").unwrap(),
870            "text/plain"
871        );
872    }
873
874    // ===========================================
875    // double-encoding test
876    // ===========================================
877
878    #[test]
879    fn test_url_decode_double_encoding_not_decoded_twice() {
880        // %252e decodes to %2e on first pass — should NOT become '.'
881        assert_eq!(url_decode("%252e"), "%2e");
882        assert_eq!(url_decode("%2565nv"), "%65nv");
883    }
884}