Skip to main content

foxy/router/
predicates.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Predicate implementations for router matching.
6//!
7//! This module provides various predicates that can be used for route matching.
8
9use async_trait::async_trait;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14use super::Predicate;
15use crate::core::{HttpMethod, ProxyError, ProxyRequest};
16use crate::warn_fmt;
17
18/// Configuration for a path predicate.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PathPredicateConfig {
21    /// The path pattern to match
22    pub pattern: String,
23}
24
25/// A predicate that matches on request path.
26#[derive(Debug)]
27pub struct PathPredicate {
28    /// The configuration for this predicate
29    #[allow(dead_code)]
30    config: PathPredicateConfig,
31    /// Compiled regex for path matching
32    regex: Regex,
33}
34
35impl PathPredicate {
36    /// Create a new path predicate with the given configuration.
37    pub fn new(config: PathPredicateConfig) -> Result<Self, ProxyError> {
38        // Convert the path pattern to a regex
39        let regex_pattern = Self::pattern_to_regex(&config.pattern);
40
41        // Compile the regex
42        let regex = Regex::new(&regex_pattern).map_err(|e| {
43            ProxyError::RoutingError(format!(
44                "Invalid path predicate regex pattern '{}': {}",
45                config.pattern, e
46            ))
47        })?;
48
49        Ok(Self { config, regex })
50    }
51
52    /// Convert a path pattern to a regex pattern.
53    fn pattern_to_regex(pattern: &str) -> String {
54        let mut regex_pattern = "^".to_string();
55
56        let mut chars = pattern.chars().peekable();
57        while let Some(c) = chars.next() {
58            match c {
59                // Handle path parameters like :id
60                ':' => {
61                    let mut param_name = String::new();
62                    while let Some(&next_char) = chars.peek() {
63                        if next_char.is_alphanumeric() || next_char == '_' {
64                            param_name.push(chars.next().unwrap());
65                        } else {
66                            break;
67                        }
68                    }
69
70                    // Add a capturing group for the parameter
71                    regex_pattern.push_str("([^/]+)");
72                }
73                // Handle wildcards like *
74                '*' => {
75                    regex_pattern.push_str("(.*)");
76                }
77                // Escape special regex characters
78                '.' | '^' | '$' | '|' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '\\' => {
79                    regex_pattern.push('\\');
80                    regex_pattern.push(c);
81                }
82                // Regular characters
83                _ => {
84                    regex_pattern.push(c);
85                }
86            }
87        }
88
89        regex_pattern.push('$');
90        regex_pattern
91    }
92}
93
94#[async_trait]
95impl Predicate for PathPredicate {
96    async fn matches(&self, request: &ProxyRequest) -> bool {
97        self.regex.is_match(&request.path)
98    }
99
100    fn predicate_type(&self) -> &str {
101        "path"
102    }
103}
104
105/// Configuration for a method predicate.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MethodPredicateConfig {
108    /// The HTTP methods to match
109    pub methods: Vec<HttpMethod>,
110}
111
112/// A predicate that matches on HTTP method.
113#[derive(Debug)]
114pub struct MethodPredicate {
115    /// The configuration for this predicate
116    config: MethodPredicateConfig,
117}
118
119impl MethodPredicate {
120    /// Create a new method predicate with the given configuration.
121    pub fn new(config: MethodPredicateConfig) -> Self {
122        Self { config }
123    }
124}
125
126#[async_trait]
127impl Predicate for MethodPredicate {
128    async fn matches(&self, request: &ProxyRequest) -> bool {
129        self.config.methods.contains(&request.method)
130    }
131
132    fn predicate_type(&self) -> &str {
133        "method"
134    }
135}
136
137/// Configuration for a header predicate.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct HeaderPredicateConfig {
140    /// The headers to match (name and value)
141    pub headers: HashMap<String, String>,
142    /// Whether to require exact match for header values
143    #[serde(default)]
144    pub exact_match: bool,
145}
146
147/// A predicate that matches on request headers.
148#[derive(Debug)]
149pub struct HeaderPredicate {
150    /// The configuration for this predicate
151    config: HeaderPredicateConfig,
152}
153
154impl HeaderPredicate {
155    /// Create a new header predicate with the given configuration.
156    pub fn new(config: HeaderPredicateConfig) -> Self {
157        Self { config }
158    }
159}
160
161#[async_trait]
162impl Predicate for HeaderPredicate {
163    async fn matches(&self, request: &ProxyRequest) -> bool {
164        for (name, expected_value) in &self.config.headers {
165            // Try to get the header
166            if let Some(header_value) = request.headers.get(name) {
167                // Convert to string for comparison
168                if let Ok(actual_value) = header_value.to_str() {
169                    if self.config.exact_match {
170                        // Exact match
171                        if actual_value != expected_value {
172                            return false;
173                        }
174                    } else {
175                        // Contains match
176                        if !actual_value.contains(expected_value) {
177                            return false;
178                        }
179                    }
180                } else {
181                    // Not a valid UTF-8 string
182                    return false;
183                }
184            } else {
185                // Header not found
186                return false;
187            }
188        }
189
190        // All headers matched
191        true
192    }
193
194    fn predicate_type(&self) -> &str {
195        "header"
196    }
197}
198
199/// Configuration for a query parameter predicate.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct QueryPredicateConfig {
202    /// The query parameters to match (name and value)
203    pub params: HashMap<String, String>,
204    /// Whether to require exact match for parameter values
205    #[serde(default)]
206    pub exact_match: bool,
207}
208
209/// A predicate that matches on query parameters.
210#[derive(Debug)]
211pub struct QueryPredicate {
212    /// The configuration for this predicate
213    config: QueryPredicateConfig,
214}
215
216impl QueryPredicate {
217    /// Create a new query predicate with the given configuration.
218    pub fn new(config: QueryPredicateConfig) -> Self {
219        Self { config }
220    }
221
222    /// Validate and sanitize a query parameter value to prevent injection attacks.
223    ///
224    /// This function checks for dangerous patterns that could be used in various
225    /// injection attacks and logs warnings when suspicious content is detected.
226    fn validate_query_value(key: &str, value: &str) -> String {
227        let mut sanitized = value.to_string();
228        let mut warnings = Vec::new();
229
230        // SECURITY: Check for CRLF injection patterns
231        if value.contains('\r') || value.contains('\n') {
232            warnings.push("CRLF injection");
233            sanitized = sanitized.replace(['\r', '\n'], "");
234        }
235
236        // SECURITY: Check for path traversal patterns
237        if value.contains("../") || value.contains("..\\") {
238            warnings.push("path traversal");
239        }
240
241        // SECURITY: Check for XSS patterns
242        let xss_patterns = ["<script", "javascript:", "onload=", "onerror=", "onclick="];
243        for pattern in &xss_patterns {
244            if value.to_lowercase().contains(pattern) {
245                warnings.push("potential XSS");
246                break;
247            }
248        }
249
250        // SECURITY: Check for SQL injection patterns
251        let sql_patterns = [
252            "union select",
253            "drop table",
254            "insert into",
255            "delete from",
256            "'or'1'='1",
257        ];
258        let lower = value.to_lowercase();
259        for pattern in &sql_patterns {
260            if lower.contains(pattern) {
261                warnings.push("potential SQL injection");
262                break;
263            }
264        }
265
266        // SECURITY: Check for command injection patterns
267        let cmd_patterns = [";", "|", "&", "`", "$", "$("];
268        for pattern in &cmd_patterns {
269            if value.contains(pattern) {
270                warnings.push("potential command injection");
271                break;
272            }
273        }
274
275        // SECURITY: Check for null byte injection
276        if value.contains('\0') {
277            warnings.push("null byte injection");
278            sanitized = sanitized.replace('\0', "");
279        }
280
281        // Log warnings for suspicious patterns
282        if !warnings.is_empty() {
283            warn_fmt!(
284                "QueryPredicate",
285                "Suspicious query parameter detected - key: '{}', value: '{}', patterns: [{}]",
286                key,
287                value,
288                warnings.join(", ")
289            );
290        }
291
292        sanitized
293    }
294
295    /// Parse query parameters from a query string with input validation.
296    fn parse_query_params(query: &str) -> HashMap<String, String> {
297        let mut params = HashMap::new();
298
299        // SECURITY: Limit query string length to prevent DoS
300        const MAX_QUERY_LENGTH: usize = 8192;
301        if query.len() > MAX_QUERY_LENGTH {
302            warn_fmt!(
303                "QueryPredicate",
304                "Query string too long: {} bytes (max: {})",
305                query.len(),
306                MAX_QUERY_LENGTH
307            );
308            return params;
309        }
310
311        for pair in query.split('&') {
312            let mut iter = pair.split('=');
313            if let (Some(key), Some(value)) = (iter.next(), iter.next()) {
314                // SECURITY: URL decode the key and value
315                let decoded_key = urlencoding::decode(key).unwrap_or_else(|_| {
316                    warn_fmt!("QueryPredicate", "Failed to URL decode key: {}", key);
317                    key.into()
318                });
319                let decoded_value = urlencoding::decode(value).unwrap_or_else(|_| {
320                    warn_fmt!("QueryPredicate", "Failed to URL decode value: {}", value);
321                    value.into()
322                });
323
324                // SECURITY: Validate and sanitize the parameter value
325                let sanitized_value = Self::validate_query_value(&decoded_key, &decoded_value);
326
327                params.insert(decoded_key.to_string(), sanitized_value);
328            }
329        }
330
331        params
332    }
333}
334
335#[async_trait]
336impl Predicate for QueryPredicate {
337    async fn matches(&self, request: &ProxyRequest) -> bool {
338        // If no query parameters to match, then it's a match
339        if self.config.params.is_empty() {
340            return true;
341        }
342
343        // If the request has no query string, it's not a match
344        if let Some(query) = &request.query {
345            let params = Self::parse_query_params(query);
346
347            for (name, expected_value) in &self.config.params {
348                // Try to get the parameter
349                if let Some(actual_value) = params.get(name) {
350                    if self.config.exact_match {
351                        // Exact match
352                        if actual_value != expected_value {
353                            return false;
354                        }
355                    } else {
356                        // Contains match
357                        if !actual_value.contains(expected_value) {
358                            return false;
359                        }
360                    }
361                } else {
362                    // Parameter not found
363                    return false;
364                }
365            }
366
367            // All parameters matched
368            true
369        } else {
370            false
371        }
372    }
373
374    fn predicate_type(&self) -> &str {
375        "query"
376    }
377}