Skip to main content

netget/server/proxy/
filter.rs

1//! Proxy filtering and modification system
2//!
3//! This module provides sophisticated request/response filtering and modification
4//! capabilities for the MITM proxy.
5//!
6//! # Certificate Modes
7//!
8//! The proxy supports three certificate modes:
9//!
10//! 1. **Generate** - Generate a self-signed CA certificate on-demand for full MITM
11//! 2. **LoadFromFile** - Load CA certificate from specified path for full MITM
12//! 3. **None** - No MITM, pass-through origin certificates (allow/block only)
13//!
14//! ## Full MITM Mode (Generate or LoadFromFile)
15//!
16//! When a certificate is configured:
17//! - HTTPS traffic is decrypted and re-encrypted
18//! - LLM sees: method, URL, path, headers, body content
19//! - LLM can: pass, block, or modify (headers, URL, body)
20//! - HTTP traffic is also fully inspectable and modifiable
21//!
22//! ## Pass-Through Mode (None)
23//!
24//! When no certificate is configured:
25//! - HTTPS traffic passes through with origin certificate (no decryption)
26//! - LLM sees: destination host, destination port, SNI (Server Name Indication)
27//! - LLM can: allow or block only (no modifications possible)
28//! - HTTP traffic is still fully inspectable and modifiable (no TLS involved)
29
30use regex::Regex;
31use serde::{Deserialize, Serialize};
32use std::collections::HashMap;
33use std::path::PathBuf;
34
35/// Certificate mode for MITM proxy
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "mode", rename_all = "lowercase")]
38pub enum CertificateMode {
39    /// Generate a self-signed CA certificate on-demand
40    Generate,
41
42    /// Load CA certificate from file
43    LoadFromFile {
44        /// Path to CA certificate file (PEM format)
45        cert_path: PathBuf,
46        /// Path to CA private key file (PEM format)
47        key_path: PathBuf,
48    },
49
50    /// No MITM - pass through origin certificates
51    /// LLM can only allow/block based on connection info
52    None,
53}
54
55impl Default for CertificateMode {
56    fn default() -> Self {
57        CertificateMode::Generate
58    }
59}
60
61/// Information available to LLM for HTTPS connection (pass-through mode)
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct HttpsConnectionInfo {
64    /// Destination host (from CONNECT request or SNI)
65    pub destination_host: String,
66    /// Destination port
67    pub destination_port: u16,
68    /// SNI (Server Name Indication) from TLS handshake if available
69    pub sni: Option<String>,
70    /// Client IP address
71    pub client_addr: String,
72}
73
74/// Full request information available in MITM mode
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct FullRequestInfo {
77    /// HTTP method (GET, POST, etc.)
78    pub method: String,
79    /// Full URL
80    pub url: String,
81    /// URL path
82    pub path: String,
83    /// Host header
84    pub host: String,
85    /// Request headers
86    pub headers: HashMap<String, String>,
87    /// Request body (may be empty)
88    pub body: Vec<u8>,
89    /// Client IP address
90    pub client_addr: String,
91}
92
93/// Full response information available in MITM mode
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct FullResponseInfo {
96    /// HTTP status code
97    pub status: u16,
98    /// Response headers
99    pub headers: HashMap<String, String>,
100    /// Response body (may be empty)
101    pub body: Vec<u8>,
102    /// Originating request host
103    pub request_host: String,
104    /// Originating request path
105    pub request_path: String,
106}
107
108/// Filter configuration for intercepting requests
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RequestFilter {
111    /// Optional regex to match against host/server (e.g., "^api\\.example\\.com$")
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub host_regex: Option<String>,
114
115    /// Optional regex to match against URL path (e.g., "^/api/v1/.*")
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub path_regex: Option<String>,
118
119    /// Optional regex to match against HTTP method (e.g., "^(POST|PUT)$")
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub method_regex: Option<String>,
122
123    /// Optional regex to match against request headers (format: "Header-Name: value-pattern")
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub header_regex: Option<String>,
126
127    /// Optional regex to match against request body content
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub body_regex: Option<String>,
130}
131
132/// Filter configuration for intercepting HTTPS connections (pass-through mode)
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct HttpsConnectionFilter {
135    /// Optional regex to match against destination host
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub host_regex: Option<String>,
138
139    /// Optional regex to match against destination port
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub port_regex: Option<String>,
142
143    /// Optional regex to match against TLS SNI (Server Name Indication)
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub sni_regex: Option<String>,
146
147    /// Optional regex to match against client address
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub client_addr_regex: Option<String>,
150}
151
152/// Filter configuration for intercepting responses
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ResponseFilter {
155    /// Optional regex to match against response status code (e.g., "^(4|5)\\d{2}$" for errors)
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub status_regex: Option<String>,
158
159    /// Optional regex to match against response headers (format: "Header-Name: value-pattern")
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub header_regex: Option<String>,
162
163    /// Optional regex to match against response body content
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub body_regex: Option<String>,
166
167    /// Optional regex to match against originating request host (to filter responses by request)
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub request_host_regex: Option<String>,
170
171    /// Optional regex to match against originating request path
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub request_path_regex: Option<String>,
174}
175
176/// Actions that can be taken on a filtered request
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "action", rename_all = "lowercase")]
179pub enum RequestAction {
180    /// Pass the request through unmodified
181    Pass,
182
183    /// Block the request and return an error response
184    Block {
185        /// HTTP status code to return (default: 403)
186        #[serde(default = "default_block_status")]
187        status: u16,
188        /// Response body (default: "Request blocked by proxy")
189        #[serde(default = "default_block_body")]
190        body: String,
191    },
192
193    /// Modify the request before forwarding
194    Modify {
195        /// Headers to add or modify (key-value pairs)
196        #[serde(skip_serializing_if = "Option::is_none")]
197        headers: Option<HashMap<String, String>>,
198
199        /// Headers to remove (list of header names)
200        #[serde(skip_serializing_if = "Option::is_none")]
201        remove_headers: Option<Vec<String>>,
202
203        /// New URL path (replaces entire path)
204        #[serde(skip_serializing_if = "Option::is_none")]
205        new_path: Option<String>,
206
207        /// Query parameters to add/modify
208        #[serde(skip_serializing_if = "Option::is_none")]
209        query_params: Option<HashMap<String, String>>,
210
211        /// Complete body replacement
212        #[serde(skip_serializing_if = "Option::is_none")]
213        new_body: Option<String>,
214
215        /// Regex-based body replacement (pattern -> replacement)
216        #[serde(skip_serializing_if = "Option::is_none")]
217        body_replacements: Option<Vec<RegexReplacement>>,
218    },
219}
220
221/// Actions that can be taken on a filtered response
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(tag = "action", rename_all = "lowercase")]
224pub enum ResponseAction {
225    /// Pass the response through unmodified
226    Pass,
227
228    /// Block the response and return a different one
229    Block {
230        /// HTTP status code to return (default: 502)
231        #[serde(default = "default_response_block_status")]
232        status: u16,
233        /// Response body (default: "Response blocked by proxy")
234        #[serde(default = "default_response_block_body")]
235        body: String,
236    },
237
238    /// Modify the response before returning to client
239    Modify {
240        /// New status code
241        #[serde(skip_serializing_if = "Option::is_none")]
242        status: Option<u16>,
243
244        /// Headers to add or modify (key-value pairs)
245        #[serde(skip_serializing_if = "Option::is_none")]
246        headers: Option<HashMap<String, String>>,
247
248        /// Headers to remove (list of header names)
249        #[serde(skip_serializing_if = "Option::is_none")]
250        remove_headers: Option<Vec<String>>,
251
252        /// Complete body replacement
253        #[serde(skip_serializing_if = "Option::is_none")]
254        new_body: Option<String>,
255
256        /// Regex-based body replacement (pattern -> replacement)
257        #[serde(skip_serializing_if = "Option::is_none")]
258        body_replacements: Option<Vec<RegexReplacement>>,
259    },
260}
261
262/// Regex-based text replacement
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct RegexReplacement {
265    /// Regex pattern to match
266    pub pattern: String,
267    /// Replacement text (supports capture groups like $1, $2)
268    pub replacement: String,
269}
270
271/// Actions available for HTTPS connections in pass-through mode (no MITM)
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(tag = "action", rename_all = "lowercase")]
274pub enum HttpsConnectionAction {
275    /// Allow the HTTPS connection to proceed
276    Allow,
277
278    /// Block the HTTPS connection
279    Block {
280        /// Optional message to log
281        #[serde(skip_serializing_if = "Option::is_none")]
282        reason: Option<String>,
283    },
284}
285
286/// Complete proxy filter configuration
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ProxyFilterConfig {
289    /// Certificate mode for MITM proxy
290    #[serde(default)]
291    pub certificate_mode: CertificateMode,
292
293    /// List of request filters (if empty, intercept all requests)
294    #[serde(default)]
295    pub request_filters: Vec<RequestFilter>,
296
297    /// List of response filters (if empty, intercept all responses)
298    #[serde(default)]
299    pub response_filters: Vec<ResponseFilter>,
300
301    /// List of HTTPS connection filters (pass-through mode only)
302    #[serde(default)]
303    pub https_connection_filters: Vec<HttpsConnectionFilter>,
304
305    /// If true, only intercept requests that match filters (default: intercept all)
306    #[serde(default)]
307    pub request_filter_mode: FilterMode,
308
309    /// If true, only intercept responses that match filters (default: intercept all)
310    #[serde(default)]
311    pub response_filter_mode: FilterMode,
312
313    /// Filter mode for HTTPS connections (pass-through mode)
314    #[serde(default)]
315    pub https_connection_filter_mode: FilterMode,
316}
317
318impl Default for ProxyFilterConfig {
319    fn default() -> Self {
320        Self {
321            certificate_mode: CertificateMode::None, // Default to pass-through mode
322            request_filters: Vec::new(),
323            response_filters: Vec::new(),
324            https_connection_filters: Vec::new(),
325            request_filter_mode: FilterMode::All,
326            response_filter_mode: FilterMode::All,
327            https_connection_filter_mode: FilterMode::All,
328        }
329    }
330}
331
332/// Filter mode determines behavior when no filters match
333#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
334#[serde(rename_all = "lowercase")]
335pub enum FilterMode {
336    /// Intercept everything (consult LLM for all requests/responses)
337    #[default]
338    All,
339
340    /// Only intercept if filters match (pass through others without LLM)
341    MatchOnly,
342
343    /// Don't intercept anything (pass everything through)
344    None,
345}
346
347// Default values for serde
348/// Headers the proxy frames itself when it rewrites a message. A model-added
349/// `Content-Length` beside the one the proxy computes, or a `Transfer-Encoding: chunked`
350/// over a body the proxy writes whole, is the ambiguity request-smuggling and
351/// response-desync attacks are built on, so neither may be added through `headers`
352/// (`remove_headers` may still name them).
353pub const FRAMING_HEADERS: [&str; 2] = ["content-length", "transfer-encoding"];
354
355/// A header name is an RFC 9110 token.
356///
357/// The names and values in `handle_request_modify` / `handle_response_modify` come from the
358/// model, and the model reads the peer's own request, so a prompt-injected value such as
359/// `"x\r\nContent-Length: 0\r\n\r\nGET /admin HTTP/1.1"` would otherwise be written into
360/// the forwarded message verbatim — one header turning into a second request upstream, or a
361/// second response to the client. These checks are applied where the action is executed and
362/// again where the bytes are written.
363pub fn check_header_name(name: &str) -> anyhow::Result<()> {
364    anyhow::ensure!(
365        !name.is_empty()
366            && name.len() <= 256
367            && name
368                .bytes()
369                .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)),
370        "header name {name:?} is not an HTTP token"
371    );
372    Ok(())
373}
374
375/// A header value carries no CR, LF or other control character (HTAB excepted).
376pub fn check_header_value(name: &str, value: &str) -> anyhow::Result<()> {
377    anyhow::ensure!(
378        value.len() <= 8192 && !value.chars().any(|c| c.is_control() && c != '\t'),
379        "header {name:?} contains a control character (CR/LF would split the message)"
380    );
381    Ok(())
382}
383
384/// Every added header is a token with a control-free value and is not a framing header.
385pub fn check_modify_headers(headers: &HashMap<String, String>) -> anyhow::Result<()> {
386    anyhow::ensure!(headers.len() <= 64, "at most 64 headers");
387    for (name, value) in headers {
388        check_header_name(name)?;
389        check_header_value(name, value)?;
390        anyhow::ensure!(
391            !FRAMING_HEADERS.contains(&name.to_ascii_lowercase().as_str()),
392            "header {name:?} is set by the proxy from the body it writes; it cannot be added \
393             (it may be named in remove_headers)"
394        );
395    }
396    Ok(())
397}
398
399/// A piece of the request target (`new_path`, a query name or value) carries no whitespace
400/// or control character: the request line is space-delimited and CRLF-terminated, so either
401/// would end the line early.
402pub fn check_request_target_part(what: &str, value: &str) -> anyhow::Result<()> {
403    anyhow::ensure!(
404        value.len() <= 8192 && !value.chars().any(|c| c.is_ascii_whitespace() || c.is_control()),
405        "{what} {value:?} contains whitespace or a control character; it would end the request line"
406    );
407    Ok(())
408}
409
410/// Every query name and value passes `check_request_target_part`.
411pub fn check_query_params(params: &HashMap<String, String>) -> anyhow::Result<()> {
412    anyhow::ensure!(params.len() <= 64, "at most 64 query_params");
413    for (name, value) in params {
414        check_request_target_part("query_params name", name)?;
415        check_request_target_part("query_params value", value)?;
416    }
417    Ok(())
418}
419
420fn default_block_status() -> u16 {
421    403
422}
423fn default_block_body() -> String {
424    "Request blocked by proxy".to_string()
425}
426fn default_response_block_status() -> u16 {
427    502
428}
429fn default_response_block_body() -> String {
430    "Response blocked by proxy".to_string()
431}
432
433impl RequestFilter {
434    /// Check if this filter matches the given request
435    pub fn matches(
436        &self,
437        host: &str,
438        path: &str,
439        method: &str,
440        headers: &HashMap<String, String>,
441        body: &[u8],
442    ) -> bool {
443        // If any pattern is specified and doesn't match, return false
444
445        if let Some(pattern) = &self.host_regex {
446            if let Ok(re) = Regex::new(pattern) {
447                if !re.is_match(host) {
448                    return false;
449                }
450            }
451        }
452
453        if let Some(pattern) = &self.path_regex {
454            if let Ok(re) = Regex::new(pattern) {
455                if !re.is_match(path) {
456                    return false;
457                }
458            }
459        }
460
461        if let Some(pattern) = &self.method_regex {
462            if let Ok(re) = Regex::new(pattern) {
463                if !re.is_match(method) {
464                    return false;
465                }
466            }
467        }
468
469        if let Some(pattern) = &self.header_regex {
470            if let Ok(re) = Regex::new(pattern) {
471                // Format headers as "Name: Value" and check if any match
472                let headers_text: String = headers
473                    .iter()
474                    .map(|(k, v)| format!("{}: {}", k, v))
475                    .collect::<Vec<_>>()
476                    .join("\n");
477
478                if !re.is_match(&headers_text) {
479                    return false;
480                }
481            }
482        }
483
484        if let Some(pattern) = &self.body_regex {
485            if let Ok(re) = Regex::new(pattern) {
486                if let Ok(body_str) = std::str::from_utf8(body) {
487                    if !re.is_match(body_str) {
488                        return false;
489                    }
490                } else {
491                    // Binary body doesn't match text pattern
492                    return false;
493                }
494            }
495        }
496
497        // All specified patterns matched
498        true
499    }
500}
501
502impl HttpsConnectionFilter {
503    /// Check if this filter matches the given HTTPS connection
504    pub fn matches(
505        &self,
506        destination_host: &str,
507        destination_port: u16,
508        sni: Option<&str>,
509        client_addr: &str,
510    ) -> bool {
511        // If any pattern is specified and doesn't match, return false
512
513        if let Some(pattern) = &self.host_regex {
514            if let Ok(re) = Regex::new(pattern) {
515                if !re.is_match(destination_host) {
516                    return false;
517                }
518            }
519        }
520
521        if let Some(pattern) = &self.port_regex {
522            if let Ok(re) = Regex::new(pattern) {
523                if !re.is_match(&destination_port.to_string()) {
524                    return false;
525                }
526            }
527        }
528
529        if let Some(pattern) = &self.sni_regex {
530            if let Ok(re) = Regex::new(pattern) {
531                if let Some(sni_value) = sni {
532                    if !re.is_match(sni_value) {
533                        return false;
534                    }
535                } else {
536                    // SNI pattern specified but SNI not available
537                    return false;
538                }
539            }
540        }
541
542        if let Some(pattern) = &self.client_addr_regex {
543            if let Ok(re) = Regex::new(pattern) {
544                if !re.is_match(client_addr) {
545                    return false;
546                }
547            }
548        }
549
550        // All specified patterns matched
551        true
552    }
553}
554
555impl ResponseFilter {
556    /// Check if this filter matches the given response
557    pub fn matches(
558        &self,
559        status: u16,
560        headers: &HashMap<String, String>,
561        body: &[u8],
562        request_host: Option<&str>,
563        request_path: Option<&str>,
564    ) -> bool {
565        // If any pattern is specified and doesn't match, return false
566
567        if let Some(pattern) = &self.status_regex {
568            if let Ok(re) = Regex::new(pattern) {
569                if !re.is_match(&status.to_string()) {
570                    return false;
571                }
572            }
573        }
574
575        if let Some(pattern) = &self.header_regex {
576            if let Ok(re) = Regex::new(pattern) {
577                // Format headers as "Name: Value" and check if any match
578                let headers_text: String = headers
579                    .iter()
580                    .map(|(k, v)| format!("{}: {}", k, v))
581                    .collect::<Vec<_>>()
582                    .join("\n");
583
584                if !re.is_match(&headers_text) {
585                    return false;
586                }
587            }
588        }
589
590        if let Some(pattern) = &self.body_regex {
591            if let Ok(re) = Regex::new(pattern) {
592                if let Ok(body_str) = std::str::from_utf8(body) {
593                    if !re.is_match(body_str) {
594                        return false;
595                    }
596                } else {
597                    return false;
598                }
599            }
600        }
601
602        if let Some(pattern) = &self.request_host_regex {
603            if let Ok(re) = Regex::new(pattern) {
604                if let Some(host) = request_host {
605                    if !re.is_match(host) {
606                        return false;
607                    }
608                } else {
609                    return false;
610                }
611            }
612        }
613
614        if let Some(pattern) = &self.request_path_regex {
615            if let Ok(re) = Regex::new(pattern) {
616                if let Some(path) = request_path {
617                    if !re.is_match(path) {
618                        return false;
619                    }
620                } else {
621                    return false;
622                }
623            }
624        }
625
626        // All specified patterns matched
627        true
628    }
629}
630
631impl ProxyFilterConfig {
632    /// Check if MITM mode is enabled (certificate available)
633    pub fn is_mitm_mode(&self) -> bool {
634        !matches!(self.certificate_mode, CertificateMode::None)
635    }
636
637    /// Check if a request should be intercepted for LLM consultation
638    pub fn should_intercept_request(
639        &self,
640        host: &str,
641        path: &str,
642        method: &str,
643        headers: &HashMap<String, String>,
644        body: &[u8],
645    ) -> bool {
646        match self.request_filter_mode {
647            FilterMode::All => true,
648            FilterMode::None => false,
649            FilterMode::MatchOnly => {
650                if self.request_filters.is_empty() {
651                    // No filters defined, don't intercept
652                    false
653                } else {
654                    // Intercept if any filter matches
655                    self.request_filters
656                        .iter()
657                        .any(|f| f.matches(host, path, method, headers, body))
658                }
659            }
660        }
661    }
662
663    /// Check if a response should be intercepted for LLM consultation
664    pub fn should_intercept_response(
665        &self,
666        status: u16,
667        headers: &HashMap<String, String>,
668        body: &[u8],
669        request_host: Option<&str>,
670        request_path: Option<&str>,
671    ) -> bool {
672        match self.response_filter_mode {
673            FilterMode::All => true,
674            FilterMode::None => false,
675            FilterMode::MatchOnly => {
676                if self.response_filters.is_empty() {
677                    // No filters defined, don't intercept
678                    false
679                } else {
680                    // Intercept if any filter matches
681                    self.response_filters
682                        .iter()
683                        .any(|f| f.matches(status, headers, body, request_host, request_path))
684                }
685            }
686        }
687    }
688
689    /// Check if an HTTPS connection (pass-through mode) should be intercepted for LLM consultation
690    pub fn should_intercept_https_connection(
691        &self,
692        destination_host: &str,
693        destination_port: u16,
694        sni: Option<&str>,
695        client_addr: &str,
696    ) -> bool {
697        // Only applies in pass-through mode (no MITM)
698        if self.is_mitm_mode() {
699            return false;
700        }
701
702        match self.https_connection_filter_mode {
703            FilterMode::All => true,
704            FilterMode::None => false,
705            FilterMode::MatchOnly => {
706                if self.https_connection_filters.is_empty() {
707                    // No filters defined, don't intercept
708                    false
709                } else {
710                    // Intercept if any filter matches
711                    self.https_connection_filters
712                        .iter()
713                        .any(|f| f.matches(destination_host, destination_port, sni, client_addr))
714                }
715            }
716        }
717    }
718
719    /// Check if this HTTP response should be inspected by LLM
720    pub fn should_inspect_response(&self, _request_info: &FullRequestInfo) -> bool {
721        // For now, inspect all responses in MITM mode
722        // TODO: Add response-specific filters based on status code, content-type, etc.
723        match self.response_filter_mode {
724            FilterMode::None => false,
725            FilterMode::All => true,
726            FilterMode::MatchOnly => {
727                // Check if any response filter matches
728                // For now, default to true if filters exist
729                !self.response_filters.is_empty()
730            }
731        }
732    }
733}