relay_filter/
csp.rs

1//! Implements event filtering for events originating from CSP endpoints
2//!
3//! Events originating from a CSP message can be filtered based on the source URL
4
5use relay_event_schema::protocol::Csp;
6
7use crate::{CspFilterConfig, FilterStatKey, Filterable};
8
9/// Checks if the event is a CSP Event from one of the disallowed sources.
10fn matches<It, S>(csp: Option<&Csp>, disallowed_sources: It) -> bool
11where
12    It: IntoIterator<Item = S>,
13    S: AsRef<str>,
14{
15    // parse the sources for easy processing
16    let disallowed_sources: Vec<SchemeDomainPort> = disallowed_sources
17        .into_iter()
18        .map(|origin| -> SchemeDomainPort { origin.as_ref().into() })
19        .collect();
20
21    if let Some(csp) = csp {
22        if matches_any_origin(csp.blocked_uri.as_str(), &disallowed_sources) {
23            return true;
24        }
25        if matches_any_origin(csp.source_file.as_str(), &disallowed_sources) {
26            return true;
27        }
28        if matches_any_origin(csp.document_uri.as_str(), &disallowed_sources) {
29            return true;
30        }
31    }
32    false
33}
34
35/// Filters CSP events based on disallowed sources.
36pub fn should_filter<F>(item: &F, config: &CspFilterConfig) -> Result<(), FilterStatKey>
37where
38    F: Filterable,
39{
40    if matches(item.csp(), &config.disallowed_sources) {
41        Err(FilterStatKey::InvalidCsp)
42    } else {
43        Ok(())
44    }
45}
46
47/// A pattern used to match allowed paths.
48///
49/// Scheme, domain and port are extracted from an url,
50/// they may be either a string (to be matched exactly, case insensitive)
51/// or None (matches anything in the respective position).
52#[derive(Hash, PartialEq, Eq)]
53pub struct SchemeDomainPort {
54    /// The scheme of the url.
55    pub scheme: Option<String>,
56    /// The domain of the url.
57    pub domain: Option<String>,
58    /// The port of the url.
59    pub port: Option<String>,
60}
61
62impl From<&str> for SchemeDomainPort {
63    /// parse a string into a SchemaDomainPort pattern
64    fn from(url: &str) -> SchemeDomainPort {
65        /// converts string into patterns for SchemeDomainPort
66        /// the convention is that a "*" matches everything which
67        /// we encode as a None (same as the absence of the pattern)
68        fn normalize(pattern: &str) -> Option<String> {
69            if pattern == "*" {
70                None
71            } else {
72                Some(pattern.to_lowercase())
73            }
74        }
75
76        //split the scheme from the url
77        let scheme_idx = url.find("://");
78        let (scheme, rest) = if let Some(idx) = scheme_idx {
79            (normalize(&url[..idx]), &url[idx + 3..]) // chop after the scheme + the "://" delimiter
80        } else {
81            (None, url) // no scheme, chop nothing form original string
82        };
83
84        // extract domain:port from the rest of the url
85        let end_domain_idx = rest.find('/');
86        let domain_port = if let Some(end_domain_idx) = end_domain_idx {
87            &rest[..end_domain_idx] // remove the path from rest
88        } else {
89            rest // no path, use everything
90        };
91
92        // split the domain and the port
93        let ipv6_end_bracket_idx = domain_port.rfind(']');
94        let port_separator_idx = if let Some(end_bracket_idx) = ipv6_end_bracket_idx {
95            // we have an ipv6 address, find the port separator after the closing bracket
96            domain_port[end_bracket_idx..]
97                .rfind(':')
98                .map(|x| x + end_bracket_idx)
99        } else {
100            // no ipv6 address, find the port separator in the whole string
101            domain_port.rfind(':')
102        };
103        let (domain, port) = if let Some(port_separator_idx) = port_separator_idx {
104            //we have a port separator, split the string into domain and port
105            (
106                normalize(&domain_port[..port_separator_idx]),
107                normalize(&domain_port[port_separator_idx + 1..]),
108            )
109        } else {
110            (normalize(domain_port), None) // no port, whole string represents the domain
111        };
112
113        SchemeDomainPort {
114            scheme,
115            domain,
116            port,
117        }
118    }
119}
120
121/// Checks if a url satisfies one of the specified origins.
122///
123/// An origin specification may be in any of the following formats:
124///
125///  - `http://domain.com[:port]`
126///  - an exact match is required
127///  - `*`: anything goes
128///  - `*.domain.com`: matches domain.com and any subdomains
129///  - `*:port`: matches any hostname as long as the port matches
130pub fn matches_any_origin(url: Option<&str>, origins: &[SchemeDomainPort]) -> bool {
131    // if we have a "*" (Any) option, anything matches so don't bother going forward
132    if origins
133        .iter()
134        .any(|o| o.scheme.is_none() && o.port.is_none() && o.domain.is_none())
135    {
136        return true;
137    }
138
139    if let Some(url) = url {
140        let url = SchemeDomainPort::from(url);
141
142        for origin in origins {
143            if origin.scheme.is_some() && url.scheme != origin.scheme {
144                continue; // scheme not matched
145            }
146            if origin.port.is_some() && url.port != origin.port {
147                continue; // port not matched
148            }
149            if origin.domain.is_some() && url.domain != origin.domain {
150                // no direct match for domain, look for  partial patterns (e.g. "*.domain.com")
151                if let (Some(origin_domain), Some(domain)) = (&origin.domain, &url.domain) {
152                    if origin_domain.starts_with('*')
153                        && ((*domain).ends_with(origin_domain.get(1..).unwrap_or(""))
154                            || domain.as_str() == origin_domain.get(2..).unwrap_or(""))
155                    {
156                        return true; // partial domain pattern match
157                    }
158                }
159                continue; // domain not matched
160            }
161            // if we are here all patterns have matched so we are done
162            return true;
163        }
164    }
165    false
166}
167
168#[cfg(test)]
169mod tests {
170    use relay_event_schema::protocol::{Csp, Event, EventType};
171    use relay_protocol::Annotated;
172
173    use super::*;
174
175    fn get_csp_event(
176        blocked_uri: Option<&str>,
177        source_file: Option<&str>,
178        document_uri: Option<&str>,
179    ) -> Event {
180        fn annotated_string_or_none(val: Option<&str>) -> Annotated<String> {
181            match val {
182                None => Annotated::empty(),
183                Some(val) => Annotated::from(val.to_string()),
184            }
185        }
186        Event {
187            ty: Annotated::from(EventType::Csp),
188            csp: Annotated::from(Csp {
189                blocked_uri: annotated_string_or_none(blocked_uri),
190                source_file: annotated_string_or_none(source_file),
191                document_uri: annotated_string_or_none(document_uri),
192                ..Csp::default()
193            }),
194            ..Event::default()
195        }
196    }
197
198    #[test]
199    fn test_scheme_domain_port() {
200        let examples = &[
201            ("*", None, None, None),
202            ("*://*", None, None, None),
203            ("*://*:*", None, None, None),
204            ("https://*", Some("https"), None, None),
205            ("https://*.abc.net", Some("https"), Some("*.abc.net"), None),
206            ("https://*:*", Some("https"), None, None),
207            ("x.y.z", None, Some("x.y.z"), None),
208            ("x.y.z:*", None, Some("x.y.z"), None),
209            ("*://x.y.z:*", None, Some("x.y.z"), None),
210            ("*://*.x.y.z:*", None, Some("*.x.y.z"), None),
211            ("*:8000", None, None, Some("8000")),
212            ("*://*:8000", None, None, Some("8000")),
213            ("http://x.y.z", Some("http"), Some("x.y.z"), None),
214            ("http://*:8000", Some("http"), None, Some("8000")),
215            ("abc:8000", None, Some("abc"), Some("8000")),
216            ("*.abc.com:8000", None, Some("*.abc.com"), Some("8000")),
217            ("*.com:86", None, Some("*.com"), Some("86")),
218            (
219                "http://abc.com:86",
220                Some("http"),
221                Some("abc.com"),
222                Some("86"),
223            ),
224            (
225                "http://x.y.z:4000",
226                Some("http"),
227                Some("x.y.z"),
228                Some("4000"),
229            ),
230            ("http://", Some("http"), Some(""), None),
231            ("abc.com/[something]", None, Some("abc.com"), None),
232            ("abc.com/something]:", None, Some("abc.com"), None),
233            ("abc.co]m/[something:", None, Some("abc.co]m"), None),
234            ("]abc.com:9000", None, Some("]abc.com"), Some("9000")),
235            (
236                "https://api.example.com/foo/00000000-0000-0000-0000-000000000000?includes[]=user&includes[]=image&includes[]=author&includes[]=tag",
237                Some("https"),
238                Some("api.example.com"),
239                None,
240            ),
241        ];
242
243        for (url, scheme, domain, port) in examples {
244            let actual: SchemeDomainPort = (*url).into();
245            assert_eq!(
246                (actual.scheme, actual.domain, actual.port),
247                (
248                    scheme.map(|x| x.to_string()),
249                    domain.map(|x| x.to_string()),
250                    port.map(|x| x.to_string())
251                )
252            );
253        }
254    }
255
256    #[test]
257    fn test_scheme_domain_port_with_ip() {
258        let examples = [
259            (
260                "http://192.168.1.1:3000",
261                Some("http"),
262                Some("192.168.1.1"),
263                Some("3000"),
264            ),
265            ("192.168.1.1", None, Some("192.168.1.1"), None),
266            ("[fd45:7aa3:7ae4::]", None, Some("[fd45:7aa3:7ae4::]"), None),
267            ("http://172.16.*.*", Some("http"), Some("172.16.*.*"), None),
268            (
269                "http://[1fff:0:a88:85a3::ac1f]:8001",
270                Some("http"),
271                Some("[1fff:0:a88:85a3::ac1f]"),
272                Some("8001"),
273            ),
274            // invalid IPv6 for localhost since it's not inside brackets
275            ("::1", None, Some(":"), Some("1")),
276            ("[::1]", None, Some("[::1]"), None),
277            (
278                "http://[fe80::862a:fdff:fe78:a2bf%13]",
279                Some("http"),
280                Some("[fe80::862a:fdff:fe78:a2bf%13]"),
281                None,
282            ),
283            // invalid addresses. although these results don't represent correct results,
284            // they are here to make sure the application won't crash.
285            ("192.168.1.1.1", None, Some("192.168.1.1.1"), None),
286            ("192.168.1.300", None, Some("192.168.1.300"), None),
287            (
288                "[2001:0db8:85a3:::8a2e:0370:7334]",
289                None,
290                Some("[2001:0db8:85a3:::8a2e:0370:7334]"),
291                None,
292            ),
293            ("[fe80::1::]", None, Some("[fe80::1::]"), None),
294            ("fe80::1::", None, Some("fe80::1:"), Some("")),
295            (
296                "[2001:0db8:85a3:xyz::8a2e:0370:7334]",
297                None,
298                Some("[2001:0db8:85a3:xyz::8a2e:0370:7334]"),
299                None,
300            ),
301            (
302                "2001:0db8:85a3:xyz::8a2e:0370:7334",
303                None,
304                Some("2001:0db8:85a3:xyz::8a2e:0370"),
305                Some("7334"),
306            ),
307            ("192.168.0.1/24", None, Some("192.168.0.1"), None),
308        ];
309
310        for (url, scheme, domain, port) in examples {
311            let actual = SchemeDomainPort::from(url);
312            assert_eq!(
313                (actual.scheme, actual.domain, actual.port),
314                (
315                    scheme.map(|x| x.to_string()),
316                    domain.map(|x| x.to_string()),
317                    port.map(|x| x.to_string())
318                )
319            );
320        }
321    }
322
323    #[test]
324    fn test_matches_any_origin() {
325        let examples = &[
326            //MATCH
327            //generic matches
328            ("http://abc1.com", vec!["*://*:*", "bbc.com"], true),
329            ("http://abc2.com", vec!["*:*", "bbc.com"], true),
330            ("http://abc3.com", vec!["*", "bbc.com"], true),
331            ("http://abc4.com", vec!["http://*", "bbc.com"], true),
332            (
333                "http://abc5.com",
334                vec!["http://abc5.com:*", "bbc.com"],
335                true,
336            ),
337            ("http://abc.com:80", vec!["*://*:*", "bbc.com"], true),
338            ("http://abc.com:81", vec!["*:*", "bbc.com"], true),
339            ("http://abc.com:82", vec!["*:82", "bbc.com"], true),
340            ("http://abc.com:83", vec!["http://*:83", "bbc.com"], true),
341            ("http://abc.com:84", vec!["abc.com:*", "bbc.com"], true),
342            //partial domain matches
343            ("http://abc.com:85", vec!["*.abc.com:85", "bbc.com"], true),
344            ("http://abc.com:86", vec!["*.com:86"], true),
345            ("http://abc.com:86", vec!["*.com:86", "bbc.com"], true),
346            ("http://abc.def.ghc.com:87", vec!["*.com:87"], true),
347            ("http://abc.def.ghc.com:88", vec!["*.ghc.com:88"], true),
348            ("http://abc.def.ghc.com:89", vec!["*.def.ghc.com:89"], true),
349            //exact matches
350            ("http://abc.com:90", vec!["abc.com", "bbc.com"], true),
351            ("http://abc.com:91", vec!["abc.com:91", "bbc.com"], true),
352            ("http://abc.com:92", vec!["http://abc.com:92"], true),
353            ("http://abc.com:93", vec!["http://abc.com", "bbc.com"], true),
354            //matches in various positions
355            ("http://abc6.com", vec!["abc6.com", "bbc.com"], true),
356            ("http://abc7.com", vec!["bbc.com", "abc7.com"], true),
357            ("http://abc8.com", vec!["bbc.com", "abc8.com", "def"], true),
358            //NON MATCH
359            //different domain
360            (
361                "http://abc9.com",
362                vec!["http://other.com", "bbc.com"],
363                false,
364            ),
365            ("http://abc10.com", vec!["http://*.other.com", "bbc"], false),
366            ("abc11.com", vec!["*.other.com", "bbc"], false),
367            //different scheme
368            (
369                "https://abc12.com",
370                vec!["http://abc12.com", "bbc.com"],
371                false,
372            ),
373            //different port
374            (
375                "http://abc13.com:80",
376                vec!["http://abc13.com:8080", "bbc.com"],
377                false,
378            ),
379            // this used to crash
380            ("http://y:80", vec!["http://x"], false),
381            // starts on both ends
382            (
383                "https://abc.software.example.com",
384                vec!["*abc.software.example.com*"],
385                false,
386            ),
387        ];
388
389        for (url, origins, expected) in examples {
390            let origins: Vec<_> = origins
391                .iter()
392                .map(|url| SchemeDomainPort::from(*url))
393                .collect();
394            let actual = matches_any_origin(Some(*url), &origins[..]);
395            assert_eq!(*expected, actual, "Could not match {url}.");
396        }
397    }
398
399    #[test]
400    fn test_filters_known_blocked_source_files() {
401        let event = get_csp_event(None, Some("http://known.bad.com"), None);
402        let config = CspFilterConfig {
403            disallowed_sources: vec!["http://known.bad.com".to_string()],
404        };
405
406        let actual = should_filter(&event, &config);
407        assert_ne!(
408            actual,
409            Ok(()),
410            "CSP filter should have filtered known bad source file"
411        );
412    }
413
414    #[test]
415    fn test_does_not_filter_benign_source_files() {
416        let event = get_csp_event(None, Some("http://good.file.com"), None);
417        let config = CspFilterConfig {
418            disallowed_sources: vec!["http://known.bad.com".to_string()],
419        };
420
421        let actual = should_filter(&event, &config);
422        assert_eq!(
423            actual,
424            Ok(()),
425            "CSP filter should have NOT filtered good source file"
426        );
427    }
428
429    #[test]
430    fn test_filters_known_document_uris() {
431        let event = get_csp_event(None, None, Some("http://known.bad.com"));
432        let config = CspFilterConfig {
433            disallowed_sources: vec!["http://known.bad.com".to_string()],
434        };
435
436        let actual = should_filter(&event, &config);
437        assert_ne!(
438            actual,
439            Ok(()),
440            "CSP filter should have filtered known document uri"
441        );
442    }
443
444    #[test]
445    fn test_filters_known_blocked_uris() {
446        let event = get_csp_event(Some("http://known.bad.com"), None, None);
447        let config = CspFilterConfig {
448            disallowed_sources: vec!["http://known.bad.com".to_string()],
449        };
450
451        let actual = should_filter(&event, &config);
452        assert_ne!(
453            actual,
454            Ok(()),
455            "CSP filter should have filtered known blocked uri"
456        );
457    }
458
459    #[test]
460    fn test_does_not_filter_benign_uris() {
461        let event = get_csp_event(Some("http://good.file.com"), None, None);
462        let config = CspFilterConfig {
463            disallowed_sources: vec!["http://known.bad.com".to_string()],
464        };
465
466        let actual = should_filter(&event, &config);
467        assert_eq!(
468            actual,
469            Ok(()),
470            "CSP filter should have NOT filtered unknown blocked uri"
471        );
472    }
473
474    #[test]
475    fn test_does_not_filter_non_csp_messages() {
476        let mut event = get_csp_event(Some("http://known.bad.com"), None, None);
477        event.ty = Annotated::from(EventType::Transaction);
478        let config = CspFilterConfig {
479            disallowed_sources: vec!["http://known.bad.com".to_string()],
480        };
481
482        let actual = should_filter(&event, &config);
483        assert_eq!(
484            actual,
485            Ok(()),
486            "CSP filter should have NOT filtered non CSP event"
487        );
488    }
489
490    fn get_disallowed_sources() -> Vec<String> {
491        vec![
492            "about".to_string(),
493            "ms-browser-extension".to_string(),
494            "*.superfish.com".to_string(),
495            "chrome://*".to_string(),
496            "chrome-extension://*".to_string(),
497            "chromeinvokeimmediate://*".to_string(),
498            "chromenull://*".to_string(),
499            "localhost".to_string(),
500        ]
501    }
502
503    /// Copy the test cases from Sentry
504    #[test]
505    fn test_sentry_csp_filter_compatibility_bad_reports() {
506        let examples = &[
507            (Some("about"), None),
508            (Some("ms-browser-extension"), None),
509            (Some("http://foo.superfish.com"), None),
510            (None, Some("chrome-extension://fdsa")),
511            (None, Some("http://localhost:8000")),
512            (None, Some("http://localhost")),
513            (None, Some("http://foo.superfish.com")),
514        ];
515
516        for (blocked_uri, source_file) in examples {
517            let event = get_csp_event(*blocked_uri, *source_file, None);
518            let config = CspFilterConfig {
519                disallowed_sources: get_disallowed_sources(),
520            };
521
522            let actual = should_filter(&event, &config);
523            assert_ne!(
524                actual,
525                Ok(()),
526                "CSP filter should have filtered  bad request {blocked_uri:?} {source_file:?}"
527            );
528        }
529    }
530
531    #[test]
532    fn test_sentry_csp_filter_compatibility_good_reports() {
533        let examples = &[
534            (Some("http://example.com"), None),
535            (None, Some("http://example.com")),
536            (None, None),
537        ];
538
539        for (blocked_uri, source_file) in examples {
540            let event = get_csp_event(*blocked_uri, *source_file, None);
541            let config = CspFilterConfig {
542                disallowed_sources: get_disallowed_sources(),
543            };
544
545            let actual = should_filter(&event, &config);
546            assert_eq!(
547                actual,
548                Ok(()),
549                "CSP filter should have  NOT filtered  request {blocked_uri:?} {source_file:?}"
550            );
551        }
552    }
553}