Skip to main content

hypermail/
filter.rs

1use crate::config::Config;
2use crate::date::{iso_to_secs, parse_rfc2822_date};
3use crate::message::FilteredReason;
4use regex::{Regex, RegexBuilder};
5
6/// Maximum length of a filter regex pattern (admin config). Prevents pathological patterns.
7const MAX_REGEX_PATTERN_LEN: usize = 512;
8
9/// Maximum length of a single string matched against filter regexes.
10const MAX_REGEX_INPUT_LEN: usize = 64 * 1024;
11
12/// Compile-time DFA/NFA size budget for filter regexes (ReDoS mitigation).
13const REGEX_SIZE_LIMIT: usize = 1 << 20; // 1 MiB compiled size
14const REGEX_DFA_SIZE_LIMIT: usize = 1 << 20;
15
16/// Compile a filter pattern with length and compiled-size guards.
17fn compile_filter_regex(pattern: &str) -> Option<Regex> {
18    if pattern.len() > MAX_REGEX_PATTERN_LEN {
19        log::warn!("Filter regex pattern exceeds {} bytes; ignoring", MAX_REGEX_PATTERN_LEN);
20        return None;
21    }
22    match RegexBuilder::new(pattern)
23        .size_limit(REGEX_SIZE_LIMIT)
24        .dfa_size_limit(REGEX_DFA_SIZE_LIMIT)
25        .build()
26    {
27        Ok(r) => Some(r),
28        Err(e) => {
29            log::warn!("Invalid or too-complex filter regex '{}': {}", pattern, e);
30            None
31        },
32    }
33}
34
35fn truncate_for_regex(s: &str) -> &str {
36    if s.len() <= MAX_REGEX_INPUT_LEN {
37        s
38    } else {
39        &s[..s.floor_char_boundary(MAX_REGEX_INPUT_LEN)]
40    }
41}
42
43pub fn check_header_filter(headers: &[(String, String)], filter_list: &[String]) -> Vec<usize> {
44    let mut matches = Vec::new();
45    for (i, pattern) in filter_list.iter().enumerate() {
46        let re = match compile_filter_regex(pattern) {
47            Some(r) => r,
48            None => continue,
49        };
50        for (name, value) in headers {
51            let header_line = format!("{}: {}", name, value);
52            let name_t = truncate_for_regex(name);
53            let value_t = truncate_for_regex(value);
54            let line_t = truncate_for_regex(&header_line);
55            if re.is_match(line_t) || re.is_match(name_t) || re.is_match(value_t) {
56                matches.push(i);
57                break;
58            }
59        }
60    }
61    if matches.is_empty() {
62        matches
63    } else {
64        vec![matches[0]]
65    }
66}
67
68pub fn check_body_filter(body_lines: &[String], filter_list: &[String]) -> Vec<usize> {
69    let mut matches = Vec::new();
70    for (i, pattern) in filter_list.iter().enumerate() {
71        let re = match compile_filter_regex(pattern) {
72            Some(r) => r,
73            None => continue,
74        };
75        for line in body_lines {
76            if re.is_match(truncate_for_regex(line)) {
77                matches.push(i);
78                break;
79            }
80        }
81    }
82    matches
83}
84
85pub fn check_deleted_headers(headers: &[(String, String)], deleted_list: &[String]) -> bool {
86    for (name, value) in headers {
87        if deleted_list.iter().any(|d| d.eq_ignore_ascii_case(name))
88            && value.trim().eq_ignore_ascii_case("yes")
89        {
90            return true;
91        }
92    }
93    false
94}
95
96fn parse_date_flexible(s: &str) -> Option<i64> {
97    parse_rfc2822_date(s).ok().or_else(|| iso_to_secs(s).ok())
98}
99
100pub fn check_expires_headers(headers: &[(String, String)], expires_list: &[String]) -> bool {
101    let now = std::time::SystemTime::now()
102        .duration_since(std::time::UNIX_EPOCH)
103        .unwrap_or_default()
104        .as_secs() as i64;
105    for (name, value) in headers {
106        if expires_list.iter().any(|e| e.eq_ignore_ascii_case(name)) {
107            if let Some(t) = parse_date_flexible(value.trim()) {
108                if t < now {
109                    return true;
110                }
111            }
112        }
113    }
114    false
115}
116
117pub fn check_delete_age(date: i64, delete_older: Option<&str>, delete_newer: Option<&str>) -> i32 {
118    let mut result = 0;
119    if let Some(older) = delete_older {
120        match parse_date_flexible(older) {
121            Some(threshold) => {
122                if date > 0 && date < threshold {
123                    result |= FilteredReason::FilteredOld as i32;
124                }
125            },
126            None => log::warn!("Could not parse delete_older date: {}", older),
127        }
128    }
129    if let Some(newer) = delete_newer {
130        match parse_date_flexible(newer) {
131            Some(threshold) => {
132                if date > 0 && date > threshold {
133                    result |= FilteredReason::FilteredNew as i32;
134                }
135            },
136            None => log::warn!("Could not parse delete_newer date: {}", newer),
137        }
138    }
139    result
140}
141
142pub fn check_delete_msgnum(msgnum: i32, delete_list: &[String]) -> bool {
143    for entry in delete_list {
144        if let Ok(n) = entry.parse::<i32>() {
145            if n == msgnum {
146                return true;
147            }
148        }
149    }
150    false
151}
152
153pub fn require_all_matched(require_results: &[bool]) -> bool {
154    require_results.iter().all(|&r| r)
155}
156
157pub fn apply_filters(
158    msgnum: i32,
159    headers: &[(String, String)],
160    body_lines: &[String],
161    date: i64,
162    config: &Config,
163) -> (i32, Vec<bool>) {
164    let mut is_deleted = 0;
165
166    if !config.filter_out.values.is_empty()
167        && !check_header_filter(headers, &config.filter_out.values).is_empty()
168    {
169        is_deleted |= FilteredReason::FilteredOut as i32;
170    }
171
172    if !config.filter_out_full_body.values.is_empty()
173        && !check_body_filter(body_lines, &config.filter_out_full_body.values).is_empty()
174    {
175        is_deleted |= FilteredReason::FilteredOut as i32;
176    }
177
178    if !config.deleted.values.is_empty() && check_deleted_headers(headers, &config.deleted.values) {
179        is_deleted |= FilteredReason::Delete as i32;
180    }
181
182    if !config.expires.values.is_empty() && check_expires_headers(headers, &config.expires.values) {
183        is_deleted |= FilteredReason::Expire as i32;
184    }
185
186    is_deleted |=
187        check_delete_age(date, config.delete_older.as_deref(), config.delete_newer.as_deref());
188
189    if check_delete_msgnum(msgnum, &config.delete_msgnum.values) {
190        is_deleted |= FilteredReason::Delete as i32;
191    }
192
193    // Compile each require pattern once per apply_filters call (not per header/line).
194    let require_results: Vec<bool> = config
195        .filter_require
196        .values
197        .iter()
198        .map(|pattern| {
199            let re = match compile_filter_regex(pattern) {
200                Some(r) => r,
201                None => return false,
202            };
203            headers.iter().any(|(name, value)| {
204                let header_line = format!("{}: {}", name, value);
205                re.is_match(truncate_for_regex(&header_line))
206                    || re.is_match(truncate_for_regex(name))
207                    || re.is_match(truncate_for_regex(value))
208            })
209        })
210        .collect();
211
212    let require_body_results: Vec<bool> = config
213        .filter_require_full_body
214        .values
215        .iter()
216        .map(|pattern| {
217            let re = match compile_filter_regex(pattern) {
218                Some(r) => r,
219                None => return false,
220            };
221            body_lines.iter().any(|line| re.is_match(truncate_for_regex(line)))
222        })
223        .collect();
224
225    let all_require = [require_results, require_body_results].concat();
226    (is_deleted, all_require)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::config::Config;
233
234    #[test]
235    fn test_check_header_filter_match() {
236        let headers = vec![("subject".to_string(), "test message".to_string())];
237        let filters = vec!["test".to_string()];
238        assert!(!check_header_filter(&headers, &filters).is_empty());
239    }
240
241    #[test]
242    fn test_check_header_filter_no_match() {
243        let headers = vec![("subject".to_string(), "hello".to_string())];
244        let filters = vec!["test".to_string()];
245        assert!(check_header_filter(&headers, &filters).is_empty());
246    }
247
248    #[test]
249    fn test_check_body_filter_match() {
250        let lines = vec!["hello world".to_string(), "spam content".to_string()];
251        let filters = vec!["spam".to_string()];
252        assert!(!check_body_filter(&lines, &filters).is_empty());
253    }
254
255    #[test]
256    fn test_check_body_filter_no_match() {
257        let lines = vec!["hello world".to_string()];
258        let filters = vec!["spam".to_string()];
259        assert!(check_body_filter(&lines, &filters).is_empty());
260    }
261
262    #[test]
263    fn test_check_deleted_headers_match() {
264        let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
265        let deleted = vec!["X-Hypermail-Deleted".to_string()];
266        assert!(check_deleted_headers(&headers, &deleted));
267    }
268
269    #[test]
270    fn test_check_deleted_headers_no_value() {
271        let headers = vec![("x-hypermail-deleted".to_string(), "no".to_string())];
272        let deleted = vec!["X-Hypermail-Deleted".to_string()];
273        assert!(!check_deleted_headers(&headers, &deleted));
274    }
275
276    #[test]
277    fn test_check_expires_past() {
278        let headers = vec![("expires".to_string(), "2000-01-01T00:00:00Z".to_string())];
279        let expires = vec!["Expires".to_string()];
280        assert!(check_expires_headers(&headers, &expires));
281    }
282
283    #[test]
284    fn test_check_expires_future() {
285        let headers = vec![("expires".to_string(), "Mon, 01 Jan 2099 00:00:00 +0000".to_string())];
286        let expires = vec!["Expires".to_string()];
287        assert!(!check_expires_headers(&headers, &expires));
288    }
289
290    #[test]
291    fn test_delete_older() {
292        let result = check_delete_age(1000, Some("2000-01-01"), None);
293        assert!(result & FilteredReason::FilteredOld as i32 != 0);
294    }
295
296    #[test]
297    fn test_delete_newer() {
298        let result = check_delete_age(9999999999, None, Some("2000-01-01"));
299        assert!(result & FilteredReason::FilteredNew as i32 != 0);
300    }
301
302    #[test]
303    fn test_check_delete_msgnum_match() {
304        let list = vec!["5".to_string(), "10".to_string()];
305        assert!(check_delete_msgnum(5, &list));
306        assert!(check_delete_msgnum(10, &list));
307        assert!(!check_delete_msgnum(7, &list));
308    }
309
310    #[test]
311    fn test_apply_filters_basic() {
312        let config = Config::default();
313        let headers = vec![("subject".to_string(), "hello".to_string())];
314        let body = vec!["some body".to_string()];
315        let (deleted, _) = apply_filters(1, &headers, &body, 1000, &config);
316        assert_eq!(deleted, 0);
317    }
318
319    #[test]
320    fn test_require_all_matched_true() {
321        assert!(require_all_matched(&[true, true, true]));
322    }
323
324    #[test]
325    fn test_require_all_matched_false() {
326        assert!(!require_all_matched(&[true, false, true]));
327    }
328
329    #[test]
330    fn test_require_all_matched_empty_is_true() {
331        assert!(require_all_matched(&[]));
332    }
333
334    #[test]
335    fn test_apply_filters_filter_out_header_match() {
336        use crate::message::FilteredReason;
337        let mut config = Config::default();
338        config.filter_out.values.push("spam".to_string());
339        let headers = vec![("subject".to_string(), "spam message".to_string())];
340        let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
341        assert_ne!(deleted & FilteredReason::FilteredOut as i32, 0);
342    }
343
344    #[test]
345    fn test_apply_filters_deleted_header() {
346        use crate::message::FilteredReason;
347        let mut config = Config::default();
348        config.deleted.values.push("X-Hypermail-Deleted".to_string());
349        let headers = vec![("x-hypermail-deleted".to_string(), "yes".to_string())];
350        let (deleted, _) = apply_filters(1, &headers, &[], 1000, &config);
351        assert_ne!(deleted & FilteredReason::Delete as i32, 0);
352    }
353
354    #[test]
355    fn test_apply_filters_delete_msgnum() {
356        use crate::message::FilteredReason;
357        let mut config = Config::default();
358        config.delete_msgnum.values.push("42".to_string());
359        let (deleted, _) = apply_filters(42, &[], &[], 1000, &config);
360        assert_ne!(deleted & FilteredReason::Delete as i32, 0);
361    }
362
363    #[test]
364    fn test_check_header_filter_matches_header_name() {
365        let headers = vec![("x-spam-flag".to_string(), "no".to_string())];
366        let filters = vec!["x-spam-flag".to_string()];
367        assert!(!check_header_filter(&headers, &filters).is_empty());
368    }
369
370    #[test]
371    fn test_oversized_regex_pattern_ignored() {
372        let headers = vec![("subject".to_string(), "test".to_string())];
373        let huge = "a".repeat(MAX_REGEX_PATTERN_LEN + 1);
374        let filters = vec![huge];
375        assert!(check_header_filter(&headers, &filters).is_empty());
376    }
377}