Skip to main content

relay_event_schema/protocol/
security_report.rs

1//! Contains definitions for the security report interfaces.
2//!
3//! The security interface is CSP.
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7use std::fmt::{self, Write};
8
9use relay_protocol::{Annotated, Empty, FromValue, IntoValue, Object, Value};
10use serde::de::{self, IgnoredAny};
11use serde::{Deserialize, Deserializer, Serialize};
12use url::Url;
13
14use crate::processor::ProcessValue;
15use crate::protocol::{
16    Event, HeaderName, HeaderValue, Headers, LogEntry, PairList, Request, TagEntry, Tags,
17};
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub struct InvalidSecurityError;
21
22impl fmt::Display for InvalidSecurityError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "invalid security report")
25    }
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum CspDirective {
30    BaseUri,
31    ChildSrc,
32    ConnectSrc,
33    DefaultSrc,
34    FencedFrameSrc,
35    FontSrc,
36    FormAction,
37    FrameAncestors,
38    FrameSrc,
39    ImgSrc,
40    ManifestSrc,
41    MediaSrc,
42    ObjectSrc,
43    PluginTypes,
44    PrefetchSrc,
45    Referrer,
46    ScriptSrc,
47    ScriptSrcAttr,
48    ScriptSrcElem,
49    StyleSrc,
50    StyleSrcElem,
51    StyleSrcAttr,
52    UpgradeInsecureRequests,
53    WorkerSrc,
54    Sandbox,
55    NavigateTo,
56    ReportUri,
57    ReportTo,
58    BlockAllMixedContent,
59    RequireSriFor,
60    RequireTrustedTypesFor,
61    TrustedTypes,
62}
63
64relay_common::derive_fromstr_and_display!(CspDirective, InvalidSecurityError, {
65    CspDirective::BaseUri => "base-uri",
66    CspDirective::ChildSrc => "child-src",
67    CspDirective::ConnectSrc => "connect-src",
68    CspDirective::DefaultSrc => "default-src",
69    CspDirective::FencedFrameSrc => "fenced-frame-src",
70    CspDirective::FontSrc => "font-src",
71    CspDirective::FormAction => "form-action",
72    CspDirective::FrameAncestors => "frame-ancestors",
73    CspDirective::FrameSrc => "frame-src",
74    CspDirective::ImgSrc => "img-src",
75    CspDirective::ManifestSrc => "manifest-src",
76    CspDirective::MediaSrc => "media-src",
77    CspDirective::ObjectSrc => "object-src",
78    CspDirective::PluginTypes => "plugin-types",
79    CspDirective::PrefetchSrc => "prefetch-src",
80    CspDirective::Referrer => "referrer",
81    CspDirective::ScriptSrc => "script-src",
82    CspDirective::ScriptSrcAttr => "script-src-attr",
83    CspDirective::ScriptSrcElem => "script-src-elem",
84    CspDirective::StyleSrc => "style-src",
85    CspDirective::StyleSrcElem => "style-src-elem",
86    CspDirective::StyleSrcAttr => "style-src-attr",
87    CspDirective::UpgradeInsecureRequests => "upgrade-insecure-requests",
88    CspDirective::WorkerSrc => "worker-src",
89    CspDirective::Sandbox => "sandbox",
90    CspDirective::NavigateTo => "navigate-to",
91    CspDirective::ReportUri => "report-uri",
92    CspDirective::ReportTo => "report-to",
93    CspDirective::BlockAllMixedContent => "block-all-mixed-content",
94    CspDirective::RequireSriFor => "require-sri-for",
95    CspDirective::RequireTrustedTypesFor => "require-trusted-types-for",
96    CspDirective::TrustedTypes => "trusted-types",
97});
98
99relay_common::impl_str_serde!(CspDirective, "a csp directive");
100
101fn is_local(uri: &str) -> bool {
102    matches!(uri, "" | "self" | "'self'")
103}
104
105fn schema_uses_host(schema: &str) -> bool {
106    // List of schemas with host (netloc) from Python's urlunsplit:
107    // see <https://github.com/python/cpython/blob/1eac437e8da106a626efffe9fce1cb47dbf5be35/Lib/urllib/parse.py#L51>
108    //
109    // Only modification: "" is set to false, since there is a separate check in the urlunsplit
110    // implementation that omits the leading "//" in that case.
111    matches!(
112        schema,
113        "ftp"
114            | "http"
115            | "gopher"
116            | "nntp"
117            | "telnet"
118            | "imap"
119            | "wais"
120            | "file"
121            | "mms"
122            | "https"
123            | "shttp"
124            | "snews"
125            | "prospero"
126            | "rtsp"
127            | "rtspu"
128            | "rsync"
129            | "svn"
130            | "svn+ssh"
131            | "sftp"
132            | "nfs"
133            | "git"
134            | "git+ssh"
135            | "ws"
136            | "wss"
137    )
138}
139
140/// Mimicks Python's urlunsplit with all its quirks.
141fn unsplit_uri(schema: &str, host: &str) -> String {
142    if !host.is_empty() || schema_uses_host(schema) {
143        format!("{schema}://{host}")
144    } else if !schema.is_empty() {
145        format!("{schema}:{host}")
146    } else {
147        String::new()
148    }
149}
150
151fn normalize_uri(value: &str) -> Cow<'_, str> {
152    if is_local(value) {
153        return Cow::Borrowed("'self'");
154    }
155
156    // A lot of these values get reported as literally just the scheme. So a value like 'data'
157    // or 'blob', which are valid schemes, just not a uri. So we want to normalize it into a
158    // URI.
159
160    if !value.contains(':') {
161        return Cow::Owned(unsplit_uri(value, ""));
162    }
163
164    let url = match Url::parse(value) {
165        Ok(url) => url,
166        Err(_) => return Cow::Borrowed(value),
167    };
168
169    let normalized = match url.scheme() {
170        "http" | "https" => Cow::Borrowed(url.host_str().unwrap_or_default()),
171        scheme => Cow::Owned(unsplit_uri(scheme, url.host_str().unwrap_or_default())),
172    };
173
174    Cow::Owned(match url.port() {
175        Some(port) => format!("{normalized}:{port}"),
176        None => normalized.into_owned(),
177    })
178}
179
180/// Inner (useful) part of a CSP report.
181///
182/// See `Csp` for meaning of fields.
183#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
184struct CspRaw {
185    #[serde(
186        skip_serializing_if = "Option::is_none",
187        alias = "effective-directive",
188        alias = "effectiveDirective"
189    )]
190    effective_directive: Option<String>,
191    #[serde(
192        default = "CspRaw::default_blocked_uri",
193        alias = "blockedURL",
194        alias = "blocked-uri"
195    )]
196    blocked_uri: String,
197    #[serde(
198        skip_serializing_if = "Option::is_none",
199        alias = "documentURL",
200        alias = "document-uri"
201    )]
202    document_uri: Option<String>,
203    #[serde(
204        skip_serializing_if = "Option::is_none",
205        alias = "originalPolicy",
206        alias = "original-policy"
207    )]
208    original_policy: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    referrer: Option<String>,
211    #[serde(
212        default,
213        skip_serializing_if = "Option::is_none",
214        alias = "statusCode",
215        alias = "status-code",
216        deserialize_with = "de_opt_num_or_str"
217    )]
218    status_code: Option<u64>,
219    #[serde(
220        default = "String::new",
221        alias = "violatedDirective",
222        alias = "violated-directive"
223    )]
224    violated_directive: String,
225    #[serde(
226        skip_serializing_if = "Option::is_none",
227        alias = "sourceFile",
228        alias = "source-file"
229    )]
230    source_file: Option<String>,
231    #[serde(
232        default,
233        skip_serializing_if = "Option::is_none",
234        alias = "lineNumber",
235        alias = "line-number",
236        deserialize_with = "de_opt_num_or_str"
237    )]
238    line_number: Option<u64>,
239    #[serde(
240        default,
241        skip_serializing_if = "Option::is_none",
242        alias = "columnNumber",
243        alias = "column-number",
244        deserialize_with = "de_opt_num_or_str"
245    )]
246    column_number: Option<u64>,
247    #[serde(
248        skip_serializing_if = "Option::is_none",
249        alias = "scriptSample",
250        alias = "script-sample",
251        alias = "sample"
252    )]
253    script_sample: Option<String>,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    disposition: Option<String>,
256
257    #[serde(flatten)]
258    other: BTreeMap<String, serde_json::Value>,
259}
260
261fn de_opt_num_or_str<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
262where
263    D: Deserializer<'de>,
264{
265    #[derive(Deserialize)]
266    #[serde(untagged)]
267    enum NumOrStr<'a> {
268        Num(u64),
269        Str(Cow<'a, str>),
270    }
271
272    Option::<NumOrStr>::deserialize(deserializer)?
273        .map(|status_code| match status_code {
274            NumOrStr::Num(num) => Ok(num),
275            NumOrStr::Str(s) => s.parse(),
276        })
277        .transpose()
278        .map_err(de::Error::custom)
279}
280
281impl CspRaw {
282    fn default_blocked_uri() -> String {
283        "self".to_owned()
284    }
285
286    fn effective_directive(&self) -> Result<CspDirective, InvalidSecurityError> {
287        // Firefox doesn't send effective-directive, so parse it from
288        // violated-directive but prefer effective-directive when present.
289        // refs: https://bugzil.la/1192684#c8
290
291        if let Some(directive) = &self.effective_directive {
292            // In C2P1 and CSP2, violated_directive and possibly effective_directive might contain
293            // more information than just the CSP-directive.
294            if let Ok(parsed_directive) = directive
295                .split_once(' ')
296                .map_or(directive.as_str(), |s| s.0)
297                .parse()
298            {
299                return Ok(parsed_directive);
300            }
301        }
302
303        if let Ok(parsed_directive) = self
304            .violated_directive
305            .split_once(' ')
306            .map_or(self.violated_directive.as_str(), |s| s.0)
307            .parse()
308        {
309            Ok(parsed_directive)
310        } else {
311            Err(InvalidSecurityError)
312        }
313    }
314
315    fn get_message(&self, effective_directive: CspDirective) -> String {
316        if is_local(&self.blocked_uri) {
317            match effective_directive {
318                CspDirective::ChildSrc => "Blocked inline 'child'".to_owned(),
319                CspDirective::ConnectSrc => "Blocked inline 'connect'".to_owned(),
320                CspDirective::FontSrc => "Blocked inline 'font'".to_owned(),
321                CspDirective::ImgSrc => "Blocked inline 'image'".to_owned(),
322                CspDirective::ManifestSrc => "Blocked inline 'manifest'".to_owned(),
323                CspDirective::MediaSrc => "Blocked inline 'media'".to_owned(),
324                CspDirective::ObjectSrc => "Blocked inline 'object'".to_owned(),
325                CspDirective::ScriptSrcAttr => "Blocked unsafe 'script' element".to_owned(),
326                CspDirective::ScriptSrcElem => "Blocked inline script attribute".to_owned(),
327                CspDirective::StyleSrc => "Blocked inline 'style'".to_owned(),
328                CspDirective::StyleSrcElem => "Blocked 'style' or 'link' element".to_owned(),
329                CspDirective::StyleSrcAttr => "Blocked style attribute".to_owned(),
330                CspDirective::ScriptSrc => {
331                    if self.violated_directive.contains("'unsafe-inline'") {
332                        "Blocked unsafe inline 'script'".to_owned()
333                    } else if self.violated_directive.contains("'unsafe-eval'") {
334                        "Blocked unsafe eval() 'script'".to_owned()
335                    } else {
336                        "Blocked unsafe (eval() or inline) 'script'".to_owned()
337                    }
338                }
339                directive => format!("Blocked inline '{directive}'"),
340            }
341        } else {
342            let uri = normalize_uri(&self.blocked_uri);
343
344            match effective_directive {
345                CspDirective::ChildSrc => format!("Blocked 'child' from '{uri}'"),
346                CspDirective::ConnectSrc => format!("Blocked 'connect' from '{uri}'"),
347                CspDirective::FontSrc => format!("Blocked 'font' from '{uri}'"),
348                CspDirective::FormAction => format!("Blocked 'form' action to '{uri}'"),
349                CspDirective::ImgSrc => format!("Blocked 'image' from '{uri}'"),
350                CspDirective::ManifestSrc => format!("Blocked 'manifest' from '{uri}'"),
351                CspDirective::MediaSrc => format!("Blocked 'media' from '{uri}'"),
352                CspDirective::ObjectSrc => format!("Blocked 'object' from '{uri}'"),
353                CspDirective::ScriptSrc => format!("Blocked 'script' from '{uri}'"),
354                CspDirective::ScriptSrcAttr => {
355                    format!("Blocked inline script attribute from '{uri}'")
356                }
357                CspDirective::ScriptSrcElem => format!("Blocked 'script' from '{uri}'"),
358                CspDirective::StyleSrc => format!("Blocked 'style' from '{uri}'"),
359                CspDirective::StyleSrcElem => format!("Blocked 'style' from '{uri}'"),
360                CspDirective::StyleSrcAttr => format!("Blocked style attribute from '{uri}'"),
361                directive => format!("Blocked '{directive}' from '{uri}'"),
362            }
363        }
364    }
365
366    fn into_protocol(self, effective_directive: CspDirective) -> Csp {
367        Csp {
368            effective_directive: Annotated::from(effective_directive.to_string()),
369            blocked_uri: Annotated::from(self.blocked_uri),
370            document_uri: Annotated::from(self.document_uri),
371            original_policy: Annotated::from(self.original_policy),
372            referrer: Annotated::from(self.referrer),
373            status_code: Annotated::from(self.status_code),
374            violated_directive: Annotated::from(self.violated_directive),
375            source_file: Annotated::from(self.source_file),
376            line_number: Annotated::from(self.line_number),
377            column_number: Annotated::from(self.column_number),
378            script_sample: Annotated::from(self.script_sample),
379            disposition: Annotated::from(self.disposition),
380            other: self
381                .other
382                .into_iter()
383                .map(|(k, v)| (k, Annotated::from(v)))
384                .collect(),
385        }
386    }
387
388    fn sanitized_blocked_uri(&self) -> String {
389        // HACK: This is 100% to work around Stripe urls that will casually put extremely sensitive
390        // information in querystrings. The real solution is to apply data scrubbing to all tags
391        // generically.
392        //
393        //    if netloc == 'api.stripe.com':
394        //      query = '' fragment = ''
395
396        let mut uri = self.blocked_uri.clone();
397
398        if uri.starts_with("https://api.stripe.com/")
399            && let Some(index) = uri.find(&['#', '?'][..])
400        {
401            uri.truncate(index);
402        }
403
404        uri
405    }
406
407    fn normalize_value<'a>(&self, value: &'a str, document_uri: &str) -> Cow<'a, str> {
408        // > If no scheme is specified, the same scheme as the one used to access the protected
409        // > document is assumed.
410        // Source: https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives
411        if let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
412            return Cow::Borrowed(value);
413        }
414
415        // Normalize a value down to 'self' if it matches the origin of document-uri FireFox
416        // transforms a 'self' value into the spelled out origin, so we want to reverse this and
417        // bring it back.
418        if value.starts_with("data:")
419            || value.starts_with("mediastream:")
420            || value.starts_with("blob:")
421            || value.starts_with("filesystem:")
422            || value.starts_with("http:")
423            || value.starts_with("https:")
424            || value.starts_with("file:")
425        {
426            if document_uri == normalize_uri(value) {
427                return Cow::Borrowed("'self'");
428            }
429
430            // Their rule had an explicit scheme, so let's respect that
431            return Cow::Borrowed(value);
432        }
433
434        // Value doesn't have a scheme, but let's see if their hostnames match at least, if so,
435        // they're the same.
436        if value == document_uri {
437            return Cow::Borrowed("'self'");
438        }
439
440        // Now we need to stitch on a scheme to the value, but let's not stitch on the boring
441        // values.
442        let original_uri = self.document_uri.as_deref().unwrap_or_default();
443        match original_uri.split_once(':').map(|x| x.0) {
444            None | Some("http" | "https") => Cow::Borrowed(value),
445            Some(scheme) => Cow::Owned(unsplit_uri(scheme, value)),
446        }
447    }
448
449    fn get_culprit(&self) -> String {
450        if self.violated_directive.is_empty() {
451            return String::new();
452        }
453
454        let mut bits = self.violated_directive.split_ascii_whitespace();
455        let mut culprit = bits.next().unwrap_or_default().to_owned();
456
457        let document_uri = self.document_uri.as_deref().unwrap_or("");
458        let normalized_uri = normalize_uri(document_uri);
459
460        for bit in bits {
461            write!(culprit, " {}", self.normalize_value(bit, &normalized_uri)).ok();
462        }
463
464        culprit
465    }
466
467    fn get_tags(&self, effective_directive: CspDirective) -> Tags {
468        let mut tags = vec![
469            Annotated::new(TagEntry(
470                Annotated::new("effective-directive".to_owned()),
471                Annotated::new(effective_directive.to_string()),
472            )),
473            Annotated::new(TagEntry(
474                Annotated::new("blocked-uri".to_owned()),
475                Annotated::new(self.sanitized_blocked_uri()),
476            )),
477        ];
478
479        if let Ok(url) = Url::parse(&self.blocked_uri)
480            && let ("http" | "https", Some(host)) = (url.scheme(), url.host_str())
481        {
482            tags.push(Annotated::new(TagEntry(
483                Annotated::new("blocked-host".to_owned()),
484                Annotated::new(host.to_owned()),
485            )));
486        }
487
488        Tags(PairList::from(tags))
489    }
490
491    fn get_request(&self) -> Request {
492        let headers = match self.referrer {
493            Some(ref referrer) if !referrer.is_empty() => {
494                Annotated::new(Headers(PairList(vec![Annotated::new((
495                    Annotated::new(HeaderName::new("Referer")),
496                    Annotated::new(HeaderValue::new(referrer.clone())),
497                ))])))
498            }
499            Some(_) | None => Annotated::empty(),
500        };
501
502        Request {
503            url: Annotated::from(self.document_uri.clone()),
504            headers,
505            ..Request::default()
506        }
507    }
508}
509
510#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
511#[serde(untagged)]
512enum CspVariant {
513    Csp {
514        #[serde(rename = "csp-report")]
515        csp_report: CspRaw,
516    },
517    /// Defines CSP report sent through the [Reporting API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API).
518    ///
519    /// This contains the [body](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody)
520    /// with actual report. We currently ignore the additional fields.
521    /// Reporting API has [slightly different format](https://csplite.com/csp66/#sample-violation-report) for the CSP report body,
522    /// but the biggest difference that browser sends the CSP reports in batches.
523    CspViolation { body: CspRaw },
524}
525
526/// The type of the CSP report which comes through the Reporting API.
527#[derive(Clone, Debug, PartialEq, Deserialize)]
528#[serde(rename_all = "kebab-case")]
529enum CspViolationType {
530    CspViolation,
531    #[serde(other)]
532    Other,
533}
534
535/// Models the content of a CSP report.
536///
537/// Note this models the older CSP reports (report-uri policy directive).
538/// The new CSP reports (using report-to policy directive) are different.
539///
540/// NOTE: This is the structure used inside the Event (serialization is based on Annotated
541/// infrastructure). We also use a version of this structure to deserialize from raw JSON
542/// via serde.
543///
544///
545/// See <https://www.w3.org/TR/CSP3/>
546#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
547pub struct Csp {
548    /// The directive whose enforcement caused the violation.
549    #[metastructure(pii = "true")]
550    pub effective_directive: Annotated<String>,
551    /// The URI of the resource that was blocked from loading by the Content Security Policy.
552    #[metastructure(pii = "true")]
553    pub blocked_uri: Annotated<String>,
554    /// The URI of the document in which the violation occurred.
555    #[metastructure(pii = "true")]
556    pub document_uri: Annotated<String>,
557    /// The original policy as specified by the Content-Security-Policy HTTP header.
558    pub original_policy: Annotated<String>,
559    /// The referrer of the document in which the violation occurred.
560    #[metastructure(pii = "true")]
561    pub referrer: Annotated<String>,
562    /// The HTTP status code of the resource on which the global object was instantiated.
563    pub status_code: Annotated<u64>,
564    /// The name of the policy section that was violated.
565    pub violated_directive: Annotated<String>,
566    /// The URL of the resource where the violation occurred.
567    #[metastructure(pii = "maybe")]
568    pub source_file: Annotated<String>,
569    /// The line number in source-file on which the violation occurred.
570    pub line_number: Annotated<u64>,
571    /// The column number in source-file on which the violation occurred.
572    pub column_number: Annotated<u64>,
573    /// The first 40 characters of the inline script, event handler, or style that caused the
574    /// violation.
575    pub script_sample: Annotated<String>,
576    /// Policy disposition (enforce or report).
577    pub disposition: Annotated<String>,
578    /// Additional arbitrary fields for forwards compatibility.
579    #[metastructure(pii = "true", additional_properties)]
580    pub other: Object<Value>,
581}
582
583impl Csp {
584    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
585        let variant = serde_json::from_slice::<CspVariant>(data)?;
586        match variant {
587            CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
588            CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
589        }
590
591        Ok(())
592    }
593
594    fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
595        let effective_directive = raw_csp
596            .effective_directive()
597            .map_err(serde::de::Error::custom)?;
598
599        event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
600        event.culprit = Annotated::new(raw_csp.get_culprit());
601        event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
602        event.request = Annotated::new(raw_csp.get_request());
603        event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
604
605        Ok(())
606    }
607}
608
609#[derive(Clone, Debug, PartialEq, Eq)]
610pub enum SecurityReportType {
611    Csp,
612    Unsupported,
613}
614
615impl SecurityReportType {
616    /// Infers the type of a security report from its payload.
617    ///
618    /// This looks into the JSON payload and tries to infer the type from keys. If no report
619    /// matches, an error is returned.
620    pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
621        #[derive(Deserialize)]
622        #[serde(rename_all = "kebab-case")]
623        struct SecurityReport {
624            #[serde(rename = "type")]
625            ty: Option<CspViolationType>,
626            csp_report: Option<IgnoredAny>,
627        }
628
629        let helper: SecurityReport = serde_json::from_slice(data)?;
630
631        Ok(if helper.csp_report.is_some() {
632            Some(SecurityReportType::Csp)
633        } else if let Some(CspViolationType::CspViolation) = helper.ty {
634            Some(SecurityReportType::Csp)
635        } else if let Some(CspViolationType::Other) = helper.ty {
636            Some(SecurityReportType::Unsupported)
637        } else {
638            None
639        })
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use relay_protocol::assert_annotated_snapshot;
647
648    #[test]
649    fn test_unsplit_uri() {
650        assert_eq!(unsplit_uri("", ""), "");
651        assert_eq!(unsplit_uri("data", ""), "data:");
652        assert_eq!(unsplit_uri("data", "foo"), "data://foo");
653        assert_eq!(unsplit_uri("http", ""), "http://");
654        assert_eq!(unsplit_uri("http", "foo"), "http://foo");
655    }
656
657    #[test]
658    fn test_normalize_uri() {
659        // Special handling for self URIs
660        assert_eq!(normalize_uri(""), "'self'");
661        assert_eq!(normalize_uri("self"), "'self'");
662
663        // Special handling for schema-only URIs
664        assert_eq!(normalize_uri("data"), "data:");
665        assert_eq!(normalize_uri("http"), "http://");
666
667        // URIs without port
668        assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
669        assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
670        assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
671        assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
672
673        // URIs with port
674        assert_eq!(
675            normalize_uri("http://notlocalhost:8000/"),
676            "notlocalhost:8000"
677        );
678        assert_eq!(
679            normalize_uri("http://notlocalhost:8000/lol.css"),
680            "notlocalhost:8000"
681        );
682
683        // Invalid URIs
684        assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
685    }
686
687    #[test]
688    fn test_csp_basic() {
689        let json = r#"{
690            "csp-report": {
691                "document-uri": "http://example.com",
692                "violated-directive": "style-src cdn.example.com",
693                "blocked-uri": "http://example.com/lol.css",
694                "effective-directive": "style-src",
695                "status-code": "200"
696            }
697        }"#;
698
699        let mut event = Event::default();
700        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
701
702        assert_annotated_snapshot!(Annotated::new(event), @r###"
703        {
704          "culprit": "style-src cdn.example.com",
705          "logentry": {
706            "formatted": "Blocked 'style' from 'example.com'"
707          },
708          "request": {
709            "url": "http://example.com"
710          },
711          "tags": [
712            [
713              "effective-directive",
714              "style-src"
715            ],
716            [
717              "blocked-uri",
718              "http://example.com/lol.css"
719            ],
720            [
721              "blocked-host",
722              "example.com"
723            ]
724          ],
725          "csp": {
726            "effective_directive": "style-src",
727            "blocked_uri": "http://example.com/lol.css",
728            "document_uri": "http://example.com",
729            "status_code": 200,
730            "violated_directive": "style-src cdn.example.com"
731          }
732        }
733        "###);
734    }
735
736    #[test]
737    fn test_csp_coerce_blocked_uri_if_missing() {
738        let json = r#"{
739            "csp-report": {
740                "document-uri": "http://example.com",
741                "effective-directive": "script-src"
742            }
743        }"#;
744
745        let mut event = Event::default();
746        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
747
748        assert_annotated_snapshot!(Annotated::new(event), @r###"
749        {
750          "culprit": "",
751          "logentry": {
752            "formatted": "Blocked unsafe (eval() or inline) 'script'"
753          },
754          "request": {
755            "url": "http://example.com"
756          },
757          "tags": [
758            [
759              "effective-directive",
760              "script-src"
761            ],
762            [
763              "blocked-uri",
764              "self"
765            ]
766          ],
767          "csp": {
768            "effective_directive": "script-src",
769            "blocked_uri": "self",
770            "document_uri": "http://example.com",
771            "violated_directive": ""
772          }
773        }
774        "###);
775    }
776
777    #[test]
778    fn test_csp_msdn() {
779        let json = r#"{
780            "csp-report": {
781                "document-uri": "https://example.com/foo/bar",
782                "referrer": "https://www.google.com/",
783                "violated-directive": "default-src self",
784                "original-policy": "default-src self; report-uri /csp-hotline.php",
785                "blocked-uri": "http://evilhackerscripts.com"
786            }
787        }"#;
788
789        let mut event = Event::default();
790        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
791
792        assert_annotated_snapshot!(Annotated::new(event), @r###"
793        {
794          "culprit": "default-src self",
795          "logentry": {
796            "formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
797          },
798          "request": {
799            "url": "https://example.com/foo/bar",
800            "headers": [
801              [
802                "Referer",
803                "https://www.google.com/"
804              ]
805            ]
806          },
807          "tags": [
808            [
809              "effective-directive",
810              "default-src"
811            ],
812            [
813              "blocked-uri",
814              "http://evilhackerscripts.com"
815            ],
816            [
817              "blocked-host",
818              "evilhackerscripts.com"
819            ]
820          ],
821          "csp": {
822            "effective_directive": "default-src",
823            "blocked_uri": "http://evilhackerscripts.com",
824            "document_uri": "https://example.com/foo/bar",
825            "original_policy": "default-src self; report-uri /csp-hotline.php",
826            "referrer": "https://www.google.com/",
827            "violated_directive": "default-src self"
828          }
829        }
830        "###);
831    }
832
833    #[test]
834    fn test_csp_real() {
835        let json = r#"{
836            "csp-report": {
837                "document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
838                "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
839                "violated-directive": "script-src",
840                "effective-directive": "script-src",
841                "original-policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
842                "disposition": "enforce",
843                "blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
844                "line-number": 24,
845                "column-number": 66270,
846                "source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
847                "status-code": 0,
848                "script-sample": ""
849            }
850        }"#;
851
852        let mut event = Event::default();
853        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
854
855        assert_annotated_snapshot!(Annotated::new(event), @r###"
856        {
857          "culprit": "script-src",
858          "logentry": {
859            "formatted": "Blocked 'script' from 'baddomain.com'"
860          },
861          "request": {
862            "url": "https://sentry.io/sentry/csp/issues/88513416/",
863            "headers": [
864              [
865                "Referer",
866                "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
867              ]
868            ]
869          },
870          "tags": [
871            [
872              "effective-directive",
873              "script-src"
874            ],
875            [
876              "blocked-uri",
877              "http://baddomain.com/test.js?_=1515535030116"
878            ],
879            [
880              "blocked-host",
881              "baddomain.com"
882            ]
883          ],
884          "csp": {
885            "effective_directive": "script-src",
886            "blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
887            "document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
888            "original_policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
889            "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
890            "status_code": 0,
891            "violated_directive": "script-src",
892            "source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
893            "line_number": 24,
894            "column_number": 66270,
895            "script_sample": "",
896            "disposition": "enforce"
897          }
898        }
899        "###);
900    }
901
902    #[test]
903    fn test_csp_sample_alias() {
904        let json = r#"{
905            "csp-report": {
906                "document-uri": "http://example.com/foo",
907                "violated-directive": "style-src http://cdn.example.com",
908                "effective-directive": "style-src",
909                "sample": "console.log(\"lo\")"
910            }
911    }"#;
912
913        let mut event = Event::default();
914        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
915        assert_annotated_snapshot!(event.csp, @r#"
916        {
917          "effective_directive": "style-src",
918          "blocked_uri": "self",
919          "document_uri": "http://example.com/foo",
920          "violated_directive": "style-src http://cdn.example.com",
921          "script_sample": "console.log(\"lo\")"
922        }
923        "#);
924    }
925
926    #[test]
927    fn test_csp_culprit_0() {
928        let json = r#"{
929            "csp-report": {
930                "document-uri": "http://example.com/foo",
931                "violated-directive": "style-src http://cdn.example.com",
932                "effective-directive": "style-src"
933            }
934        }"#;
935
936        let mut event = Event::default();
937        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
938        insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://cdn.example.com""###);
939    }
940
941    #[test]
942    fn test_csp_culprit_1() {
943        let json = r#"{
944            "csp-report": {
945                "document-uri": "http://example.com/foo",
946                "violated-directive": "style-src cdn.example.com",
947                "effective-directive": "style-src"
948            }
949        }"#;
950
951        let mut event = Event::default();
952        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
953        insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
954    }
955
956    #[test]
957    fn test_csp_culprit_2() {
958        let json = r#"{
959            "csp-report": {
960                "document-uri": "https://example.com/foo",
961                "violated-directive": "style-src cdn.example.com",
962                "effective-directive": "style-src"
963            }
964        }"#;
965
966        let mut event = Event::default();
967        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
968        insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
969    }
970
971    #[test]
972    fn test_csp_culprit_3() {
973        let json = r#"{
974            "csp-report": {
975                "document-uri": "http://example.com/foo",
976                "violated-directive": "style-src https://cdn.example.com",
977                "effective-directive": "style-src"
978            }
979        }"#;
980
981        let mut event = Event::default();
982        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
983        insta::assert_debug_snapshot!(event.culprit, @r###""style-src https://cdn.example.com""###);
984    }
985
986    #[test]
987    fn test_csp_culprit_4() {
988        let json = r#"{
989            "csp-report": {
990                "document-uri": "http://example.com/foo",
991                "violated-directive": "style-src http://example.com",
992                "effective-directive": "style-src"
993            }
994        }"#;
995
996        let mut event = Event::default();
997        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
998        insta::assert_debug_snapshot!(event.culprit, @r###""style-src 'self'""###);
999    }
1000
1001    #[test]
1002    fn test_csp_culprit_5() {
1003        let json = r#"{
1004            "csp-report": {
1005                "document-uri": "http://example.com/foo",
1006                "violated-directive": "style-src http://example2.com example.com",
1007                "effective-directive": "style-src"
1008            }
1009        }"#;
1010
1011        let mut event = Event::default();
1012        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1013        insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://example2.com 'self'""###);
1014    }
1015
1016    #[test]
1017    fn test_csp_culprit_uri_without_scheme() {
1018        // Not sure if this is a real-world example, but let's cover it anyway
1019        let json = r#"{
1020            "csp-report": {
1021                "document-uri": "example.com",
1022                "violated-directive": "style-src example2.com"
1023            }
1024        }"#;
1025
1026        let mut event = Event::default();
1027        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1028        insta::assert_debug_snapshot!(event.culprit, @r###""style-src example2.com""###);
1029    }
1030
1031    #[test]
1032    fn test_csp_tags_stripe() {
1033        // This is a regression test for potential PII in stripe URLs. PII stripping used to skip
1034        // report interfaces, which is why there is special handling.
1035
1036        let json = r#"{
1037            "csp-report": {
1038                "document-uri": "https://example.com",
1039                "blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
1040                "effective-directive": "script-src"
1041            }
1042        }"#;
1043
1044        let mut event = Event::default();
1045        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1046        insta::assert_debug_snapshot!(event.tags, @r###"
1047        Tags(
1048            PairList(
1049                [
1050                    TagEntry(
1051                        "effective-directive",
1052                        "script-src",
1053                    ),
1054                    TagEntry(
1055                        "blocked-uri",
1056                        "https://api.stripe.com/v1/tokens",
1057                    ),
1058                    TagEntry(
1059                        "blocked-host",
1060                        "api.stripe.com",
1061                    ),
1062                ],
1063            ),
1064        )
1065        "###);
1066    }
1067
1068    #[test]
1069    fn test_csp_get_message_0() {
1070        let json = r#"{
1071            "csp-report": {
1072                "document-uri": "http://example.com/foo",
1073                "effective-directive": "img-src",
1074                "blocked-uri": "http://google.com/foo"
1075            }
1076        }"#;
1077
1078        let mut event = Event::default();
1079        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1080        let message = &event.logentry.value().unwrap().formatted;
1081        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'image' from 'google.com'""###);
1082    }
1083
1084    #[test]
1085    fn test_csp_get_message_1() {
1086        let json = r#"{
1087            "csp-report": {
1088                "document-uri": "http://example.com/foo",
1089                "effective-directive": "style-src",
1090                "blocked-uri": ""
1091            }
1092        }"#;
1093
1094        let mut event = Event::default();
1095        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1096        let message = &event.logentry.value().unwrap().formatted;
1097        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked inline 'style'""###);
1098    }
1099
1100    #[test]
1101    fn test_csp_get_message_2() {
1102        let json = r#"{
1103            "csp-report": {
1104                "document-uri": "http://example.com/foo",
1105                "effective-directive": "script-src",
1106                "blocked-uri": "",
1107                "violated-directive": "script-src 'unsafe-inline'"
1108            }
1109        }"#;
1110
1111        let mut event = Event::default();
1112        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1113        let message = &event.logentry.value().unwrap().formatted;
1114        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe inline 'script'""###);
1115    }
1116
1117    #[test]
1118    fn test_csp_get_message_3() {
1119        let json = r#"{
1120            "csp-report": {
1121                "document-uri": "http://example.com/foo",
1122                "effective-directive": "script-src",
1123                "blocked-uri": "",
1124                "violated-directive": "script-src 'unsafe-eval'"
1125            }
1126        }"#;
1127
1128        let mut event = Event::default();
1129        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1130        let message = &event.logentry.value().unwrap().formatted;
1131        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe eval() 'script'""###);
1132    }
1133
1134    #[test]
1135    fn test_csp_get_message_4() {
1136        let json = r#"{
1137            "csp-report": {
1138                "document-uri": "http://example.com/foo",
1139                "effective-directive": "script-src",
1140                "blocked-uri": "",
1141                "violated-directive": "script-src example.com"
1142            }
1143        }"#;
1144
1145        let mut event = Event::default();
1146        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1147        let message = &event.logentry.value().unwrap().formatted;
1148        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe (eval() or inline) 'script'""###);
1149    }
1150
1151    #[test]
1152    fn test_csp_get_message_5() {
1153        let json = r#"{
1154            "csp-report": {
1155                "document-uri": "http://example.com/foo",
1156                "effective-directive": "script-src",
1157                "blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
1158            }
1159        }"#;
1160
1161        let mut event = Event::default();
1162        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1163        let message = &event.logentry.value().unwrap().formatted;
1164        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1165    }
1166
1167    #[test]
1168    fn test_csp_get_message_6() {
1169        let json = r#"{
1170            "csp-report": {
1171                "document-uri": "http://example.com/foo",
1172                "effective-directive": "script-src",
1173                "blocked-uri": "data"
1174            }
1175        }"#;
1176
1177        let mut event = Event::default();
1178        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1179        let message = &event.logentry.value().unwrap().formatted;
1180        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1181    }
1182
1183    #[test]
1184    fn test_csp_get_message_7() {
1185        let json = r#"{
1186            "csp-report": {
1187                "document-uri": "http://example.com/foo",
1188                "effective-directive": "style-src-elem",
1189                "blocked-uri": "http://fonts.google.com/foo"
1190            }
1191        }"#;
1192
1193        let mut event = Event::default();
1194        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1195        let message = &event.logentry.value().unwrap().formatted;
1196        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'fonts.google.com'""###);
1197    }
1198
1199    #[test]
1200    fn test_csp_get_message_8() {
1201        let json = r#"{
1202            "csp-report": {
1203                "document-uri": "http://example.com/foo",
1204                "effective-directive": "script-src-elem",
1205                "blocked-uri": "http://cdn.ajaxapis.com/foo"
1206            }
1207        }"#;
1208
1209        let mut event = Event::default();
1210        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1211        let message = &event.logentry.value().unwrap().formatted;
1212        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'cdn.ajaxapis.com'""###);
1213    }
1214
1215    #[test]
1216    fn test_csp_get_message_9() {
1217        let json = r#"{
1218            "csp-report": {
1219                "document-uri": "http://notlocalhost:8000/",
1220                "effective-directive": "style-src",
1221                "blocked-uri": "http://notlocalhost:8000/lol.css"
1222            }
1223        }"#;
1224
1225        let mut event = Event::default();
1226        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1227        let message = &event.logentry.value().unwrap().formatted;
1228        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'notlocalhost:8000'""###);
1229    }
1230
1231    #[test]
1232    fn test_security_report_type_deserializer_recognizes_csp_reports() {
1233        let csp_report_text = r#"{
1234            "csp-report": {
1235                "document-uri": "https://example.com/foo/bar",
1236                "referrer": "https://www.google.com/",
1237                "violated-directive": "default-src self",
1238                "original-policy": "default-src self; report-uri /csp-hotline.php",
1239                "blocked-uri": "http://evilhackerscripts.com"
1240            }
1241        }"#;
1242
1243        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1244        assert_eq!(report_type, Some(SecurityReportType::Csp));
1245    }
1246
1247    #[test]
1248    fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
1249        let csp_report_text = r#"{
1250          "age":0,
1251          "body":{
1252            "blockedURL":"https://example.com/tst/media/7_del.png",
1253            "disposition":"enforce",
1254            "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1255            "effectiveDirective":"img-src",
1256            "lineNumber":9,
1257            "originalPolicy":"default-src 'none'; report-to endpoint-csp;",
1258            "referrer":"https://example.com/test229/",
1259            "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1260            "statusCode":0
1261            },
1262          "type":"csp-violation",
1263          "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1264          "user_agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
1265        }"#;
1266
1267        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1268        assert_eq!(report_type, Some(SecurityReportType::Csp));
1269    }
1270
1271    #[test]
1272    fn test_security_report_type_deserializer_rejects_expect_ct_reports() {
1273        // Expect-CT is no longer a supported report type: the classifier must not recognize it.
1274        let expect_ct_report_text = r#"{
1275            "expect-ct-report": {
1276                "date-time": "2014-04-06T13:00:50Z",
1277                "hostname": "www.example.com",
1278                "port": 443,
1279                "effective-expiration-date": "2014-05-01T12:40:50Z",
1280                "served-certificate-chain": [
1281                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1282                ],
1283                "validated-certificate-chain": [
1284                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1285                ],
1286                "scts": [
1287                    {
1288                        "version": 1,
1289                        "status": "invalid",
1290                        "source": "embedded",
1291                        "serialized_sct": "ABCD=="
1292                    }
1293                ]
1294            }
1295        }"#;
1296
1297        let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
1298        assert_eq!(report_type, None);
1299    }
1300
1301    #[test]
1302    fn test_security_report_type_deserializer_rejects_expect_staple_reports() {
1303        // Expect-Staple is no longer a supported report type: the classifier must not recognize it.
1304        let expect_staple_report_text = r#"{
1305             "expect-staple-report": {
1306                "date-time": "2014-04-06T13:00:50Z",
1307                "hostname": "www.example.com",
1308                "port": 443,
1309                "response-status": "ERROR_RESPONSE",
1310                "cert-status": "REVOKED",
1311                "effective-expiration-date": "2014-05-01T12:40:50Z",
1312                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1313                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
1314            }
1315        }"#;
1316        let report_type =
1317            SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
1318        assert_eq!(report_type, None);
1319    }
1320
1321    #[test]
1322    fn test_security_report_type_deserializer_rejects_hpkp_reports() {
1323        // HPKP is no longer a supported report type: the classifier must not recognize it.
1324        let hpkp_report_text = r#"{
1325            "date-time": "2014-04-06T13:00:50Z",
1326            "hostname": "www.example.com",
1327            "port": 443,
1328            "effective-expiration-date": "2014-05-01T12:40:50Z",
1329            "include-subdomains": false,
1330            "served-certificate-chain": [
1331              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
1332            ],
1333            "validated-certificate-chain": [
1334              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
1335            ],
1336            "known-pins": [
1337              "pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
1338              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
1339            ]
1340          }"#;
1341
1342        let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
1343        assert_eq!(report_type, None);
1344    }
1345
1346    #[test]
1347    fn test_effective_directive_from_violated_directive_single() {
1348        // Example from Firefox:
1349        let csp_raw: CspRaw =
1350            serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
1351        assert!(matches!(
1352            csp_raw.effective_directive(),
1353            Ok(CspDirective::DefaultSrc)
1354        ));
1355    }
1356}