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                    && 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                continue; // domain not matched
159            }
160            // if we are here all patterns have matched so we are done
161            return true;
162        }
163    }
164    false
165}
166
167#[cfg(test)]
168mod tests {
169    use relay_event_schema::protocol::{Csp, Event, EventType};
170    use relay_protocol::Annotated;
171
172    use super::*;
173
174    fn get_csp_event(
175        blocked_uri: Option<&str>,
176        source_file: Option<&str>,
177        document_uri: Option<&str>,
178    ) -> Event {
179        fn annotated_string_or_none(val: Option<&str>) -> Annotated<String> {
180            match val {
181                None => Annotated::empty(),
182                Some(val) => Annotated::from(val.to_owned()),
183            }
184        }
185        Event {
186            ty: Annotated::from(EventType::Csp),
187            csp: Annotated::from(Csp {
188                blocked_uri: annotated_string_or_none(blocked_uri),
189                source_file: annotated_string_or_none(source_file),
190                document_uri: annotated_string_or_none(document_uri),
191                ..Csp::default()
192            }),
193            ..Event::default()
194        }
195    }
196
197    #[test]
198    fn test_scheme_domain_port() {
199        let examples = &[
200            ("*", None, None, None),
201            ("*://*", None, None, None),
202            ("*://*:*", None, None, None),
203            ("https://*", Some("https"), None, None),
204            ("https://*.abc.net", Some("https"), Some("*.abc.net"), None),
205            ("https://*:*", Some("https"), None, None),
206            ("x.y.z", None, Some("x.y.z"), 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            ("*:8000", None, None, Some("8000")),
211            ("*://*:8000", None, None, Some("8000")),
212            ("http://x.y.z", Some("http"), Some("x.y.z"), None),
213            ("http://*:8000", Some("http"), None, Some("8000")),
214            ("abc:8000", None, Some("abc"), Some("8000")),
215            ("*.abc.com:8000", None, Some("*.abc.com"), Some("8000")),
216            ("*.com:86", None, Some("*.com"), Some("86")),
217            (
218                "http://abc.com:86",
219                Some("http"),
220                Some("abc.com"),
221                Some("86"),
222            ),
223            (
224                "http://x.y.z:4000",
225                Some("http"),
226                Some("x.y.z"),
227                Some("4000"),
228            ),
229            ("http://", Some("http"), Some(""), None),
230            ("abc.com/[something]", None, Some("abc.com"), None),
231            ("abc.com/something]:", None, Some("abc.com"), None),
232            ("abc.co]m/[something:", None, Some("abc.co]m"), None),
233            ("]abc.com:9000", None, Some("]abc.com"), Some("9000")),
234            (
235                "https://api.example.com/foo/00000000-0000-0000-0000-000000000000?includes[]=user&includes[]=image&includes[]=author&includes[]=tag",
236                Some("https"),
237                Some("api.example.com"),
238                None,
239            ),
240        ];
241
242        for (url, scheme, domain, port) in examples {
243            let actual: SchemeDomainPort = (*url).into();
244            assert_eq!(
245                (actual.scheme, actual.domain, actual.port),
246                (
247                    scheme.map(|x| x.to_owned()),
248                    domain.map(|x| x.to_owned()),
249                    port.map(|x| x.to_owned())
250                )
251            );
252        }
253    }
254
255    #[test]
256    fn test_scheme_domain_port_with_ip() {
257        let examples = [
258            (
259                "http://192.168.1.1:3000",
260                Some("http"),
261                Some("192.168.1.1"),
262                Some("3000"),
263            ),
264            ("192.168.1.1", None, Some("192.168.1.1"), None),
265            ("[fd45:7aa3:7ae4::]", None, Some("[fd45:7aa3:7ae4::]"), None),
266            ("http://172.16.*.*", Some("http"), Some("172.16.*.*"), None),
267            (
268                "http://[1fff:0:a88:85a3::ac1f]:8001",
269                Some("http"),
270                Some("[1fff:0:a88:85a3::ac1f]"),
271                Some("8001"),
272            ),
273            // invalid IPv6 for localhost since it's not inside brackets
274            ("::1", None, Some(":"), Some("1")),
275            ("[::1]", None, Some("[::1]"), None),
276            (
277                "http://[fe80::862a:fdff:fe78:a2bf%13]",
278                Some("http"),
279                Some("[fe80::862a:fdff:fe78:a2bf%13]"),
280                None,
281            ),
282            // invalid addresses. although these results don't represent correct results,
283            // they are here to make sure the application won't crash.
284            ("192.168.1.1.1", None, Some("192.168.1.1.1"), None),
285            ("192.168.1.300", None, Some("192.168.1.300"), None),
286            (
287                "[2001:0db8:85a3:::8a2e:0370:7334]",
288                None,
289                Some("[2001:0db8:85a3:::8a2e:0370:7334]"),
290                None,
291            ),
292            ("[fe80::1::]", None, Some("[fe80::1::]"), None),
293            ("fe80::1::", None, Some("fe80::1:"), Some("")),
294            (
295                "[2001:0db8:85a3:xyz::8a2e:0370:7334]",
296                None,
297                Some("[2001:0db8:85a3:xyz::8a2e:0370:7334]"),
298                None,
299            ),
300            (
301                "2001:0db8:85a3:xyz::8a2e:0370:7334",
302                None,
303                Some("2001:0db8:85a3:xyz::8a2e:0370"),
304                Some("7334"),
305            ),
306            ("192.168.0.1/24", None, Some("192.168.0.1"), None),
307        ];
308
309        for (url, scheme, domain, port) in examples {
310            let actual = SchemeDomainPort::from(url);
311            assert_eq!(
312                (actual.scheme, actual.domain, actual.port),
313                (
314                    scheme.map(|x| x.to_owned()),
315                    domain.map(|x| x.to_owned()),
316                    port.map(|x| x.to_owned())
317                )
318            );
319        }
320    }
321
322    #[test]
323    fn test_matches_any_origin() {
324        let examples = &[
325            //MATCH
326            //generic matches
327            ("http://abc1.com", vec!["*://*:*", "bbc.com"], true),
328            ("http://abc2.com", vec!["*:*", "bbc.com"], true),
329            ("http://abc3.com", vec!["*", "bbc.com"], true),
330            ("http://abc4.com", vec!["http://*", "bbc.com"], true),
331            (
332                "http://abc5.com",
333                vec!["http://abc5.com:*", "bbc.com"],
334                true,
335            ),
336            ("http://abc.com:80", vec!["*://*:*", "bbc.com"], true),
337            ("http://abc.com:81", vec!["*:*", "bbc.com"], true),
338            ("http://abc.com:82", vec!["*:82", "bbc.com"], true),
339            ("http://abc.com:83", vec!["http://*:83", "bbc.com"], true),
340            ("http://abc.com:84", vec!["abc.com:*", "bbc.com"], true),
341            //partial domain matches
342            ("http://abc.com:85", vec!["*.abc.com:85", "bbc.com"], true),
343            ("http://abc.com:86", vec!["*.com:86"], true),
344            ("http://abc.com:86", vec!["*.com:86", "bbc.com"], true),
345            ("http://abc.def.ghc.com:87", vec!["*.com:87"], true),
346            ("http://abc.def.ghc.com:88", vec!["*.ghc.com:88"], true),
347            ("http://abc.def.ghc.com:89", vec!["*.def.ghc.com:89"], true),
348            //exact matches
349            ("http://abc.com:90", vec!["abc.com", "bbc.com"], true),
350            ("http://abc.com:91", vec!["abc.com:91", "bbc.com"], true),
351            ("http://abc.com:92", vec!["http://abc.com:92"], true),
352            ("http://abc.com:93", vec!["http://abc.com", "bbc.com"], true),
353            //matches in various positions
354            ("http://abc6.com", vec!["abc6.com", "bbc.com"], true),
355            ("http://abc7.com", vec!["bbc.com", "abc7.com"], true),
356            ("http://abc8.com", vec!["bbc.com", "abc8.com", "def"], true),
357            //NON MATCH
358            //different domain
359            (
360                "http://abc9.com",
361                vec!["http://other.com", "bbc.com"],
362                false,
363            ),
364            ("http://abc10.com", vec!["http://*.other.com", "bbc"], false),
365            ("abc11.com", vec!["*.other.com", "bbc"], false),
366            //different scheme
367            (
368                "https://abc12.com",
369                vec!["http://abc12.com", "bbc.com"],
370                false,
371            ),
372            //different port
373            (
374                "http://abc13.com:80",
375                vec!["http://abc13.com:8080", "bbc.com"],
376                false,
377            ),
378            // this used to crash
379            ("http://y:80", vec!["http://x"], false),
380            // starts on both ends
381            (
382                "https://abc.software.example.com",
383                vec!["*abc.software.example.com*"],
384                false,
385            ),
386        ];
387
388        for (url, origins, expected) in examples {
389            let origins: Vec<_> = origins
390                .iter()
391                .map(|url| SchemeDomainPort::from(*url))
392                .collect();
393            let actual = matches_any_origin(Some(*url), &origins[..]);
394            assert_eq!(*expected, actual, "Could not match {url}.");
395        }
396    }
397
398    #[test]
399    fn test_filters_known_blocked_source_files() {
400        let event = get_csp_event(None, Some("http://known.bad.com"), None);
401        let config = CspFilterConfig {
402            disallowed_sources: vec!["http://known.bad.com".to_owned()],
403        };
404
405        let actual = should_filter(&event, &config);
406        assert_ne!(
407            actual,
408            Ok(()),
409            "CSP filter should have filtered known bad source file"
410        );
411    }
412
413    #[test]
414    fn test_does_not_filter_benign_source_files() {
415        let event = get_csp_event(None, Some("http://good.file.com"), None);
416        let config = CspFilterConfig {
417            disallowed_sources: vec!["http://known.bad.com".to_owned()],
418        };
419
420        let actual = should_filter(&event, &config);
421        assert_eq!(
422            actual,
423            Ok(()),
424            "CSP filter should have NOT filtered good source file"
425        );
426    }
427
428    #[test]
429    fn test_filters_known_document_uris() {
430        let event = get_csp_event(None, None, Some("http://known.bad.com"));
431        let config = CspFilterConfig {
432            disallowed_sources: vec!["http://known.bad.com".to_owned()],
433        };
434
435        let actual = should_filter(&event, &config);
436        assert_ne!(
437            actual,
438            Ok(()),
439            "CSP filter should have filtered known document uri"
440        );
441    }
442
443    #[test]
444    fn test_filters_known_blocked_uris() {
445        let event = get_csp_event(Some("http://known.bad.com"), None, None);
446        let config = CspFilterConfig {
447            disallowed_sources: vec!["http://known.bad.com".to_owned()],
448        };
449
450        let actual = should_filter(&event, &config);
451        assert_ne!(
452            actual,
453            Ok(()),
454            "CSP filter should have filtered known blocked uri"
455        );
456    }
457
458    #[test]
459    fn test_does_not_filter_benign_uris() {
460        let event = get_csp_event(Some("http://good.file.com"), None, None);
461        let config = CspFilterConfig {
462            disallowed_sources: vec!["http://known.bad.com".to_owned()],
463        };
464
465        let actual = should_filter(&event, &config);
466        assert_eq!(
467            actual,
468            Ok(()),
469            "CSP filter should have NOT filtered unknown blocked uri"
470        );
471    }
472
473    #[test]
474    fn test_does_not_filter_non_csp_messages() {
475        let mut event = get_csp_event(Some("http://known.bad.com"), None, None);
476        event.ty = Annotated::from(EventType::Transaction);
477        let config = CspFilterConfig {
478            disallowed_sources: vec!["http://known.bad.com".to_owned()],
479        };
480
481        let actual = should_filter(&event, &config);
482        assert_eq!(
483            actual,
484            Ok(()),
485            "CSP filter should have NOT filtered non CSP event"
486        );
487    }
488
489    fn get_disallowed_sources() -> Vec<String> {
490        vec![
491            "about".to_owned(),
492            "ms-browser-extension".to_owned(),
493            "*.superfish.com".to_owned(),
494            "chrome://*".to_owned(),
495            "chrome-extension://*".to_owned(),
496            "chromeinvokeimmediate://*".to_owned(),
497            "chromenull://*".to_owned(),
498            "localhost".to_owned(),
499        ]
500    }
501
502    /// Copy the test cases from Sentry
503    #[test]
504    fn test_sentry_csp_filter_compatibility_bad_reports() {
505        let examples = &[
506            (Some("about"), None),
507            (Some("ms-browser-extension"), None),
508            (Some("http://foo.superfish.com"), None),
509            (None, Some("chrome-extension://fdsa")),
510            (None, Some("http://localhost:8000")),
511            (None, Some("http://localhost")),
512            (None, Some("http://foo.superfish.com")),
513        ];
514
515        for (blocked_uri, source_file) in examples {
516            let event = get_csp_event(*blocked_uri, *source_file, None);
517            let config = CspFilterConfig {
518                disallowed_sources: get_disallowed_sources(),
519            };
520
521            let actual = should_filter(&event, &config);
522            assert_ne!(
523                actual,
524                Ok(()),
525                "CSP filter should have filtered  bad request {blocked_uri:?} {source_file:?}"
526            );
527        }
528    }
529
530    #[test]
531    fn test_sentry_csp_filter_compatibility_good_reports() {
532        let examples = &[
533            (Some("http://example.com"), None),
534            (None, Some("http://example.com")),
535            (None, None),
536        ];
537
538        for (blocked_uri, source_file) in examples {
539            let event = get_csp_event(*blocked_uri, *source_file, None);
540            let config = CspFilterConfig {
541                disallowed_sources: get_disallowed_sources(),
542            };
543
544            let actual = should_filter(&event, &config);
545            assert_eq!(
546                actual,
547                Ok(()),
548                "CSP filter should have  NOT filtered  request {blocked_uri:?} {source_file:?}"
549            );
550        }
551    }
552}