relay_event_schema/protocol/
security_report.rs

1//! Contains definitions for the security report interfaces.
2//!
3//! The security interfaces are CSP, HPKP, ExpectCT and ExpectStaple.
4
5use std::borrow::Cow;
6use std::collections::BTreeMap;
7use std::fmt::{self, Write};
8
9use chrono::{DateTime, Utc};
10use relay_protocol::{Annotated, Array, Empty, FromValue, IntoValue, Object, Value};
11use serde::de::{self, Error, IgnoredAny};
12use serde::{Deserialize, Deserializer, Serialize};
13use url::Url;
14
15use crate::processor::ProcessValue;
16use crate::protocol::{
17    Event, HeaderName, HeaderValue, Headers, LogEntry, PairList, Request, TagEntry, Tags,
18};
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct InvalidSecurityError;
22
23impl fmt::Display for InvalidSecurityError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        write!(f, "invalid security report")
26    }
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum CspDirective {
31    BaseUri,
32    ChildSrc,
33    ConnectSrc,
34    DefaultSrc,
35    FencedFrameSrc,
36    FontSrc,
37    FormAction,
38    FrameAncestors,
39    FrameSrc,
40    ImgSrc,
41    ManifestSrc,
42    MediaSrc,
43    ObjectSrc,
44    PluginTypes,
45    PrefetchSrc,
46    Referrer,
47    ScriptSrc,
48    ScriptSrcAttr,
49    ScriptSrcElem,
50    StyleSrc,
51    StyleSrcElem,
52    StyleSrcAttr,
53    UpgradeInsecureRequests,
54    WorkerSrc,
55    Sandbox,
56    NavigateTo,
57    ReportUri,
58    ReportTo,
59    BlockAllMixedContent,
60    RequireSriFor,
61    RequireTrustedTypesFor,
62    TrustedTypes,
63}
64
65relay_common::derive_fromstr_and_display!(CspDirective, InvalidSecurityError, {
66    CspDirective::BaseUri => "base-uri",
67    CspDirective::ChildSrc => "child-src",
68    CspDirective::ConnectSrc => "connect-src",
69    CspDirective::DefaultSrc => "default-src",
70    CspDirective::FencedFrameSrc => "fenced-frame-src",
71    CspDirective::FontSrc => "font-src",
72    CspDirective::FormAction => "form-action",
73    CspDirective::FrameAncestors => "frame-ancestors",
74    CspDirective::FrameSrc => "frame-src",
75    CspDirective::ImgSrc => "img-src",
76    CspDirective::ManifestSrc => "manifest-src",
77    CspDirective::MediaSrc => "media-src",
78    CspDirective::ObjectSrc => "object-src",
79    CspDirective::PluginTypes => "plugin-types",
80    CspDirective::PrefetchSrc => "prefetch-src",
81    CspDirective::Referrer => "referrer",
82    CspDirective::ScriptSrc => "script-src",
83    CspDirective::ScriptSrcAttr => "script-src-attr",
84    CspDirective::ScriptSrcElem => "script-src-elem",
85    CspDirective::StyleSrc => "style-src",
86    CspDirective::StyleSrcElem => "style-src-elem",
87    CspDirective::StyleSrcAttr => "style-src-attr",
88    CspDirective::UpgradeInsecureRequests => "upgrade-insecure-requests",
89    CspDirective::WorkerSrc => "worker-src",
90    CspDirective::Sandbox => "sandbox",
91    CspDirective::NavigateTo => "navigate-to",
92    CspDirective::ReportUri => "report-uri",
93    CspDirective::ReportTo => "report-to",
94    CspDirective::BlockAllMixedContent => "block-all-mixed-content",
95    CspDirective::RequireSriFor => "require-sri-for",
96    CspDirective::RequireTrustedTypesFor => "require-trusted-types-for",
97    CspDirective::TrustedTypes => "trusted-types",
98});
99
100relay_common::impl_str_serde!(CspDirective, "a csp directive");
101
102fn is_local(uri: &str) -> bool {
103    matches!(uri, "" | "self" | "'self'")
104}
105
106fn schema_uses_host(schema: &str) -> bool {
107    // List of schemas with host (netloc) from Python's urlunsplit:
108    // see <https://github.com/python/cpython/blob/1eac437e8da106a626efffe9fce1cb47dbf5be35/Lib/urllib/parse.py#L51>
109    //
110    // Only modification: "" is set to false, since there is a separate check in the urlunsplit
111    // implementation that omits the leading "//" in that case.
112    matches!(
113        schema,
114        "ftp"
115            | "http"
116            | "gopher"
117            | "nntp"
118            | "telnet"
119            | "imap"
120            | "wais"
121            | "file"
122            | "mms"
123            | "https"
124            | "shttp"
125            | "snews"
126            | "prospero"
127            | "rtsp"
128            | "rtspu"
129            | "rsync"
130            | "svn"
131            | "svn+ssh"
132            | "sftp"
133            | "nfs"
134            | "git"
135            | "git+ssh"
136            | "ws"
137            | "wss"
138    )
139}
140
141/// Mimicks Python's urlunsplit with all its quirks.
142fn unsplit_uri(schema: &str, host: &str) -> String {
143    if !host.is_empty() || schema_uses_host(schema) {
144        format!("{schema}://{host}")
145    } else if !schema.is_empty() {
146        format!("{schema}:{host}")
147    } else {
148        String::new()
149    }
150}
151
152fn normalize_uri(value: &str) -> Cow<'_, str> {
153    if is_local(value) {
154        return Cow::Borrowed("'self'");
155    }
156
157    // A lot of these values get reported as literally just the scheme. So a value like 'data'
158    // or 'blob', which are valid schemes, just not a uri. So we want to normalize it into a
159    // URI.
160
161    if !value.contains(':') {
162        return Cow::Owned(unsplit_uri(value, ""));
163    }
164
165    let url = match Url::parse(value) {
166        Ok(url) => url,
167        Err(_) => return Cow::Borrowed(value),
168    };
169
170    let normalized = match url.scheme() {
171        "http" | "https" => Cow::Borrowed(url.host_str().unwrap_or_default()),
172        scheme => Cow::Owned(unsplit_uri(scheme, url.host_str().unwrap_or_default())),
173    };
174
175    Cow::Owned(match url.port() {
176        Some(port) => format!("{normalized}:{port}"),
177        None => normalized.into_owned(),
178    })
179}
180
181/// Inner (useful) part of a CSP report.
182///
183/// See `Csp` for meaning of fields.
184#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
185struct CspRaw {
186    #[serde(
187        skip_serializing_if = "Option::is_none",
188        alias = "effective-directive",
189        alias = "effectiveDirective"
190    )]
191    effective_directive: Option<String>,
192    #[serde(
193        default = "CspRaw::default_blocked_uri",
194        alias = "blockedURL",
195        alias = "blocked-uri"
196    )]
197    blocked_uri: String,
198    #[serde(
199        skip_serializing_if = "Option::is_none",
200        alias = "documentURL",
201        alias = "document-uri"
202    )]
203    document_uri: Option<String>,
204    #[serde(
205        skip_serializing_if = "Option::is_none",
206        alias = "originalPolicy",
207        alias = "original-policy"
208    )]
209    original_policy: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    referrer: Option<String>,
212    #[serde(
213        default,
214        skip_serializing_if = "Option::is_none",
215        alias = "statusCode",
216        alias = "status-code",
217        deserialize_with = "de_opt_num_or_str"
218    )]
219    status_code: Option<u64>,
220    #[serde(
221        default = "String::new",
222        alias = "violatedDirective",
223        alias = "violated-directive"
224    )]
225    violated_directive: String,
226    #[serde(
227        skip_serializing_if = "Option::is_none",
228        alias = "sourceFile",
229        alias = "source-file"
230    )]
231    source_file: Option<String>,
232    #[serde(
233        default,
234        skip_serializing_if = "Option::is_none",
235        alias = "lineNumber",
236        alias = "line-number",
237        deserialize_with = "de_opt_num_or_str"
238    )]
239    line_number: Option<u64>,
240    #[serde(
241        default,
242        skip_serializing_if = "Option::is_none",
243        alias = "columnNumber",
244        alias = "column-number",
245        deserialize_with = "de_opt_num_or_str"
246    )]
247    column_number: Option<u64>,
248    #[serde(
249        skip_serializing_if = "Option::is_none",
250        alias = "scriptSample",
251        alias = "script-sample",
252        alias = "sample"
253    )]
254    script_sample: Option<String>,
255    #[serde(skip_serializing_if = "Option::is_none")]
256    disposition: Option<String>,
257
258    #[serde(flatten)]
259    other: BTreeMap<String, serde_json::Value>,
260}
261
262fn de_opt_num_or_str<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
263where
264    D: Deserializer<'de>,
265{
266    #[derive(Deserialize)]
267    #[serde(untagged)]
268    enum NumOrStr<'a> {
269        Num(u64),
270        Str(Cow<'a, str>),
271    }
272
273    Option::<NumOrStr>::deserialize(deserializer)?
274        .map(|status_code| match status_code {
275            NumOrStr::Num(num) => Ok(num),
276            NumOrStr::Str(s) => s.parse(),
277        })
278        .transpose()
279        .map_err(de::Error::custom)
280}
281
282impl CspRaw {
283    fn default_blocked_uri() -> String {
284        "self".to_owned()
285    }
286
287    fn effective_directive(&self) -> Result<CspDirective, InvalidSecurityError> {
288        // Firefox doesn't send effective-directive, so parse it from
289        // violated-directive but prefer effective-directive when present.
290        // refs: https://bugzil.la/1192684#c8
291
292        if let Some(directive) = &self.effective_directive {
293            // In C2P1 and CSP2, violated_directive and possibly effective_directive might contain
294            // more information than just the CSP-directive.
295            if let Ok(parsed_directive) = directive
296                .split_once(' ')
297                .map_or(directive.as_str(), |s| s.0)
298                .parse()
299            {
300                return Ok(parsed_directive);
301            }
302        }
303
304        if let Ok(parsed_directive) = self
305            .violated_directive
306            .split_once(' ')
307            .map_or(self.violated_directive.as_str(), |s| s.0)
308            .parse()
309        {
310            Ok(parsed_directive)
311        } else {
312            Err(InvalidSecurityError)
313        }
314    }
315
316    fn get_message(&self, effective_directive: CspDirective) -> String {
317        if is_local(&self.blocked_uri) {
318            match effective_directive {
319                CspDirective::ChildSrc => "Blocked inline 'child'".to_owned(),
320                CspDirective::ConnectSrc => "Blocked inline 'connect'".to_owned(),
321                CspDirective::FontSrc => "Blocked inline 'font'".to_owned(),
322                CspDirective::ImgSrc => "Blocked inline 'image'".to_owned(),
323                CspDirective::ManifestSrc => "Blocked inline 'manifest'".to_owned(),
324                CspDirective::MediaSrc => "Blocked inline 'media'".to_owned(),
325                CspDirective::ObjectSrc => "Blocked inline 'object'".to_owned(),
326                CspDirective::ScriptSrcAttr => "Blocked unsafe 'script' element".to_owned(),
327                CspDirective::ScriptSrcElem => "Blocked inline script attribute".to_owned(),
328                CspDirective::StyleSrc => "Blocked inline 'style'".to_owned(),
329                CspDirective::StyleSrcElem => "Blocked 'style' or 'link' element".to_owned(),
330                CspDirective::StyleSrcAttr => "Blocked style attribute".to_owned(),
331                CspDirective::ScriptSrc => {
332                    if self.violated_directive.contains("'unsafe-inline'") {
333                        "Blocked unsafe inline 'script'".to_owned()
334                    } else if self.violated_directive.contains("'unsafe-eval'") {
335                        "Blocked unsafe eval() 'script'".to_owned()
336                    } else {
337                        "Blocked unsafe (eval() or inline) 'script'".to_owned()
338                    }
339                }
340                directive => format!("Blocked inline '{directive}'"),
341            }
342        } else {
343            let uri = normalize_uri(&self.blocked_uri);
344
345            match effective_directive {
346                CspDirective::ChildSrc => format!("Blocked 'child' from '{uri}'"),
347                CspDirective::ConnectSrc => format!("Blocked 'connect' from '{uri}'"),
348                CspDirective::FontSrc => format!("Blocked 'font' from '{uri}'"),
349                CspDirective::FormAction => format!("Blocked 'form' action to '{uri}'"),
350                CspDirective::ImgSrc => format!("Blocked 'image' from '{uri}'"),
351                CspDirective::ManifestSrc => format!("Blocked 'manifest' from '{uri}'"),
352                CspDirective::MediaSrc => format!("Blocked 'media' from '{uri}'"),
353                CspDirective::ObjectSrc => format!("Blocked 'object' from '{uri}'"),
354                CspDirective::ScriptSrc => format!("Blocked 'script' from '{uri}'"),
355                CspDirective::ScriptSrcAttr => {
356                    format!("Blocked inline script attribute from '{uri}'")
357                }
358                CspDirective::ScriptSrcElem => format!("Blocked 'script' from '{uri}'"),
359                CspDirective::StyleSrc => format!("Blocked 'style' from '{uri}'"),
360                CspDirective::StyleSrcElem => format!("Blocked 'style' from '{uri}'"),
361                CspDirective::StyleSrcAttr => format!("Blocked style attribute from '{uri}'"),
362                directive => format!("Blocked '{directive}' from '{uri}'"),
363            }
364        }
365    }
366
367    fn into_protocol(self, effective_directive: CspDirective) -> Csp {
368        Csp {
369            effective_directive: Annotated::from(effective_directive.to_string()),
370            blocked_uri: Annotated::from(self.blocked_uri),
371            document_uri: Annotated::from(self.document_uri),
372            original_policy: Annotated::from(self.original_policy),
373            referrer: Annotated::from(self.referrer),
374            status_code: Annotated::from(self.status_code),
375            violated_directive: Annotated::from(self.violated_directive),
376            source_file: Annotated::from(self.source_file),
377            line_number: Annotated::from(self.line_number),
378            column_number: Annotated::from(self.column_number),
379            script_sample: Annotated::from(self.script_sample),
380            disposition: Annotated::from(self.disposition),
381            other: self
382                .other
383                .into_iter()
384                .map(|(k, v)| (k, Annotated::from(v)))
385                .collect(),
386        }
387    }
388
389    fn sanitized_blocked_uri(&self) -> String {
390        // HACK: This is 100% to work around Stripe urls that will casually put extremely sensitive
391        // information in querystrings. The real solution is to apply data scrubbing to all tags
392        // generically.
393        //
394        //    if netloc == 'api.stripe.com':
395        //      query = '' fragment = ''
396
397        let mut uri = self.blocked_uri.clone();
398
399        if uri.starts_with("https://api.stripe.com/")
400            && let Some(index) = uri.find(&['#', '?'][..])
401        {
402            uri.truncate(index);
403        }
404
405        uri
406    }
407
408    fn normalize_value<'a>(&self, value: &'a str, document_uri: &str) -> Cow<'a, str> {
409        // > If no scheme is specified, the same scheme as the one used to access the protected
410        // > document is assumed.
411        // Source: https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives
412        if let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
413            return Cow::Borrowed(value);
414        }
415
416        // Normalize a value down to 'self' if it matches the origin of document-uri FireFox
417        // transforms a 'self' value into the spelled out origin, so we want to reverse this and
418        // bring it back.
419        if value.starts_with("data:")
420            || value.starts_with("mediastream:")
421            || value.starts_with("blob:")
422            || value.starts_with("filesystem:")
423            || value.starts_with("http:")
424            || value.starts_with("https:")
425            || value.starts_with("file:")
426        {
427            if document_uri == normalize_uri(value) {
428                return Cow::Borrowed("'self'");
429            }
430
431            // Their rule had an explicit scheme, so let's respect that
432            return Cow::Borrowed(value);
433        }
434
435        // Value doesn't have a scheme, but let's see if their hostnames match at least, if so,
436        // they're the same.
437        if value == document_uri {
438            return Cow::Borrowed("'self'");
439        }
440
441        // Now we need to stitch on a scheme to the value, but let's not stitch on the boring
442        // values.
443        let original_uri = self.document_uri.as_deref().unwrap_or_default();
444        match original_uri.split_once(':').map(|x| x.0) {
445            None | Some("http" | "https") => Cow::Borrowed(value),
446            Some(scheme) => Cow::Owned(unsplit_uri(scheme, value)),
447        }
448    }
449
450    fn get_culprit(&self) -> String {
451        if self.violated_directive.is_empty() {
452            return String::new();
453        }
454
455        let mut bits = self.violated_directive.split_ascii_whitespace();
456        let mut culprit = bits.next().unwrap_or_default().to_owned();
457
458        let document_uri = self.document_uri.as_deref().unwrap_or("");
459        let normalized_uri = normalize_uri(document_uri);
460
461        for bit in bits {
462            write!(culprit, " {}", self.normalize_value(bit, &normalized_uri)).ok();
463        }
464
465        culprit
466    }
467
468    fn get_tags(&self, effective_directive: CspDirective) -> Tags {
469        let mut tags = vec![
470            Annotated::new(TagEntry(
471                Annotated::new("effective-directive".to_owned()),
472                Annotated::new(effective_directive.to_string()),
473            )),
474            Annotated::new(TagEntry(
475                Annotated::new("blocked-uri".to_owned()),
476                Annotated::new(self.sanitized_blocked_uri()),
477            )),
478        ];
479
480        if let Ok(url) = Url::parse(&self.blocked_uri)
481            && let ("http" | "https", Some(host)) = (url.scheme(), url.host_str())
482        {
483            tags.push(Annotated::new(TagEntry(
484                Annotated::new("blocked-host".to_owned()),
485                Annotated::new(host.to_owned()),
486            )));
487        }
488
489        Tags(PairList::from(tags))
490    }
491
492    fn get_request(&self) -> Request {
493        let headers = match self.referrer {
494            Some(ref referrer) if !referrer.is_empty() => {
495                Annotated::new(Headers(PairList(vec![Annotated::new((
496                    Annotated::new(HeaderName::new("Referer")),
497                    Annotated::new(HeaderValue::new(referrer.clone())),
498                ))])))
499            }
500            Some(_) | None => Annotated::empty(),
501        };
502
503        Request {
504            url: Annotated::from(self.document_uri.clone()),
505            headers,
506            ..Request::default()
507        }
508    }
509}
510
511#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
512#[serde(untagged)]
513enum CspVariant {
514    Csp {
515        #[serde(rename = "csp-report")]
516        csp_report: CspRaw,
517    },
518    /// Defines CSP report sent through the [Reporting API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API).
519    ///
520    /// This contains the [body](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody)
521    /// with actual report. We currently ignore the additional fields.
522    /// Reporting API has [slightly different format](https://csplite.com/csp66/#sample-violation-report) for the CSP report body,
523    /// but the biggest difference that browser sends the CSP reports in batches.
524    CspViolation { body: CspRaw },
525}
526
527/// The type of the CSP report which comes through the Reporting API.
528#[derive(Clone, Debug, PartialEq, Deserialize)]
529#[serde(rename_all = "kebab-case")]
530enum CspViolationType {
531    CspViolation,
532    #[serde(other)]
533    Other,
534}
535
536/// Models the content of a CSP report.
537///
538/// Note this models the older CSP reports (report-uri policy directive).
539/// The new CSP reports (using report-to policy directive) are different.
540///
541/// NOTE: This is the structure used inside the Event (serialization is based on Annotated
542/// infrastructure). We also use a version of this structure to deserialize from raw JSON
543/// via serde.
544///
545///
546/// See <https://www.w3.org/TR/CSP3/>
547#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
548pub struct Csp {
549    /// The directive whose enforcement caused the violation.
550    #[metastructure(pii = "true")]
551    pub effective_directive: Annotated<String>,
552    /// The URI of the resource that was blocked from loading by the Content Security Policy.
553    #[metastructure(pii = "true")]
554    pub blocked_uri: Annotated<String>,
555    /// The URI of the document in which the violation occurred.
556    #[metastructure(pii = "true")]
557    pub document_uri: Annotated<String>,
558    /// The original policy as specified by the Content-Security-Policy HTTP header.
559    pub original_policy: Annotated<String>,
560    /// The referrer of the document in which the violation occurred.
561    #[metastructure(pii = "true")]
562    pub referrer: Annotated<String>,
563    /// The HTTP status code of the resource on which the global object was instantiated.
564    pub status_code: Annotated<u64>,
565    /// The name of the policy section that was violated.
566    pub violated_directive: Annotated<String>,
567    /// The URL of the resource where the violation occurred.
568    #[metastructure(pii = "maybe")]
569    pub source_file: Annotated<String>,
570    /// The line number in source-file on which the violation occurred.
571    pub line_number: Annotated<u64>,
572    /// The column number in source-file on which the violation occurred.
573    pub column_number: Annotated<u64>,
574    /// The first 40 characters of the inline script, event handler, or style that caused the
575    /// violation.
576    pub script_sample: Annotated<String>,
577    /// Policy disposition (enforce or report).
578    pub disposition: Annotated<String>,
579    /// Additional arbitrary fields for forwards compatibility.
580    #[metastructure(pii = "true", additional_properties)]
581    pub other: Object<Value>,
582}
583
584impl Csp {
585    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
586        let variant = serde_json::from_slice::<CspVariant>(data)?;
587        match variant {
588            CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
589            CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
590        }
591
592        Ok(())
593    }
594
595    fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
596        let effective_directive = raw_csp
597            .effective_directive()
598            .map_err(serde::de::Error::custom)?;
599
600        event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
601        event.culprit = Annotated::new(raw_csp.get_culprit());
602        event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
603        event.request = Annotated::new(raw_csp.get_request());
604        event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
605
606        Ok(())
607    }
608}
609
610#[derive(Clone, Copy, Debug, PartialEq, Eq)]
611enum ExpectCtStatus {
612    Unknown,
613    Valid,
614    Invalid,
615}
616
617relay_common::derive_fromstr_and_display!(ExpectCtStatus, InvalidSecurityError, {
618    ExpectCtStatus::Unknown => "unknown",
619    ExpectCtStatus::Valid => "valid",
620    ExpectCtStatus::Invalid => "invalid",
621});
622
623relay_common::impl_str_serde!(ExpectCtStatus, "an expect-ct status");
624
625#[derive(Clone, Copy, Debug, PartialEq, Eq)]
626enum ExpectCtSource {
627    TlsExtension,
628    Ocsp,
629    Embedded,
630}
631
632relay_common::derive_fromstr_and_display!(ExpectCtSource, InvalidSecurityError, {
633            ExpectCtSource::TlsExtension => "tls-extension",
634            ExpectCtSource::Ocsp => "ocsp",
635            ExpectCtSource::Embedded => "embedded",
636});
637
638relay_common::impl_str_serde!(ExpectCtSource, "an expect-ct source");
639
640#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
641struct SingleCertificateTimestampRaw {
642    version: Option<i64>,
643    status: Option<ExpectCtStatus>,
644    source: Option<ExpectCtSource>,
645    serialized_sct: Option<String>, // NOT kebab-case!
646}
647
648impl SingleCertificateTimestampRaw {
649    fn into_protocol(self) -> SingleCertificateTimestamp {
650        SingleCertificateTimestamp {
651            version: Annotated::from(self.version),
652            status: Annotated::from(self.status.map(|s| s.to_string())),
653            source: Annotated::from(self.source.map(|s| s.to_string())),
654            serialized_sct: Annotated::from(self.serialized_sct),
655        }
656    }
657}
658
659#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
660#[serde(rename_all = "kebab-case")]
661struct ExpectCtRaw {
662    #[serde(with = "serde_date_time_3339")]
663    date_time: Option<DateTime<Utc>>,
664    hostname: String,
665    port: Option<i64>,
666    scheme: Option<String>,
667    #[serde(with = "serde_date_time_3339")]
668    effective_expiration_date: Option<DateTime<Utc>>,
669    served_certificate_chain: Option<Vec<String>>,
670    validated_certificate_chain: Option<Vec<String>>,
671    scts: Option<Vec<SingleCertificateTimestampRaw>>,
672    failure_mode: Option<String>,
673    test_report: Option<bool>,
674}
675
676mod serde_date_time_3339 {
677    use serde::de::Visitor;
678
679    use super::*;
680
681    pub fn serialize<S>(date_time: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
682    where
683        S: serde::Serializer,
684    {
685        match date_time {
686            None => serializer.serialize_none(),
687            Some(d) => serializer.serialize_str(&d.to_rfc3339()),
688        }
689    }
690
691    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
692    where
693        D: serde::Deserializer<'de>,
694    {
695        struct DateTimeVisitor;
696
697        impl Visitor<'_> for DateTimeVisitor {
698            type Value = Option<DateTime<Utc>>;
699
700            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
701                formatter.write_str("expected a date-time in RFC 3339 format")
702            }
703
704            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
705            where
706                E: serde::de::Error,
707            {
708                DateTime::parse_from_rfc3339(s)
709                    .map(|d| Some(d.with_timezone(&Utc)))
710                    .map_err(serde::de::Error::custom)
711            }
712
713            fn visit_none<E>(self) -> Result<Self::Value, E>
714            where
715                E: Error,
716            {
717                Ok(None)
718            }
719        }
720
721        deserializer.deserialize_any(DateTimeVisitor)
722    }
723}
724
725impl ExpectCtRaw {
726    fn get_message(&self) -> String {
727        format!("Expect-CT failed for '{}'", self.hostname)
728    }
729
730    fn into_protocol(self) -> ExpectCt {
731        ExpectCt {
732            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
733            hostname: Annotated::from(self.hostname),
734            port: Annotated::from(self.port),
735            scheme: Annotated::from(self.scheme),
736            effective_expiration_date: Annotated::from(
737                self.effective_expiration_date.map(|d| d.to_rfc3339()),
738            ),
739            served_certificate_chain: Annotated::new(
740                self.served_certificate_chain
741                    .map(|s| s.into_iter().map(Annotated::from).collect())
742                    .unwrap_or_default(),
743            ),
744
745            validated_certificate_chain: Annotated::new(
746                self.validated_certificate_chain
747                    .map(|v| v.into_iter().map(Annotated::from).collect())
748                    .unwrap_or_default(),
749            ),
750            scts: Annotated::from(self.scts.map(|scts| {
751                scts.into_iter()
752                    .map(|elm| Annotated::from(elm.into_protocol()))
753                    .collect()
754            })),
755            failure_mode: Annotated::from(self.failure_mode),
756            test_report: Annotated::from(self.test_report),
757        }
758    }
759
760    fn get_culprit(&self) -> String {
761        self.hostname.clone()
762    }
763
764    fn get_tags(&self) -> Tags {
765        let mut tags = vec![Annotated::new(TagEntry(
766            Annotated::new("hostname".to_owned()),
767            Annotated::new(self.hostname.clone()),
768        ))];
769
770        if let Some(port) = self.port {
771            tags.push(Annotated::new(TagEntry(
772                Annotated::new("port".to_owned()),
773                Annotated::new(port.to_string()),
774            )));
775        }
776
777        Tags(PairList::from(tags))
778    }
779
780    fn get_request(&self) -> Request {
781        Request {
782            url: Annotated::from(self.hostname.clone()),
783            ..Request::default()
784        }
785    }
786}
787
788#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
789#[serde(rename_all = "kebab-case")]
790struct ExpectCtReportRaw {
791    expect_ct_report: ExpectCtRaw,
792}
793
794/// Object used in ExpectCt reports
795///
796/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>.
797#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
798pub struct SingleCertificateTimestamp {
799    pub version: Annotated<i64>,
800    pub status: Annotated<String>,
801    pub source: Annotated<String>,
802    pub serialized_sct: Annotated<String>,
803}
804
805/// Expect CT security report sent by user agent (browser).
806///
807/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>
808#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
809pub struct ExpectCt {
810    /// Date time in rfc3339 format YYYY-MM-DDTHH:MM:DD{.FFFFFF}(Z|+/-HH:MM)
811    /// UTC time that the UA observed the CT compliance failure
812    pub date_time: Annotated<String>,
813    /// The hostname to which the UA made the original request that failed the CT compliance check.
814    pub hostname: Annotated<String>,
815    pub port: Annotated<i64>,
816    pub scheme: Annotated<String>,
817    /// Date time in rfc3339 format
818    pub effective_expiration_date: Annotated<String>,
819    pub served_certificate_chain: Annotated<Array<String>>,
820    pub validated_certificate_chain: Annotated<Array<String>>,
821    pub scts: Annotated<Array<SingleCertificateTimestamp>>,
822    pub failure_mode: Annotated<String>,
823    pub test_report: Annotated<bool>,
824}
825
826impl ExpectCt {
827    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
828        let raw_report = serde_json::from_slice::<ExpectCtReportRaw>(data)?;
829        let raw_expect_ct = raw_report.expect_ct_report;
830
831        event.logentry = Annotated::new(LogEntry::from(raw_expect_ct.get_message()));
832        event.culprit = Annotated::new(raw_expect_ct.get_culprit());
833        event.tags = Annotated::new(raw_expect_ct.get_tags());
834        event.request = Annotated::new(raw_expect_ct.get_request());
835        event.expectct = Annotated::new(raw_expect_ct.into_protocol());
836
837        Ok(())
838    }
839}
840
841/// Defines external, RFC-defined schema we accept, while `Hpkp` defines our own schema.
842///
843/// See `Hpkp` for meaning of fields.
844#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
845#[serde(rename_all = "kebab-case")]
846struct HpkpRaw {
847    #[serde(skip_serializing_if = "Option::is_none")]
848    date_time: Option<DateTime<Utc>>,
849    hostname: String,
850    #[serde(skip_serializing_if = "Option::is_none")]
851    port: Option<u64>,
852    #[serde(skip_serializing_if = "Option::is_none")]
853    effective_expiration_date: Option<DateTime<Utc>>,
854    #[serde(skip_serializing_if = "Option::is_none")]
855    include_subdomains: Option<bool>,
856    #[serde(skip_serializing_if = "Option::is_none")]
857    noted_hostname: Option<String>,
858    #[serde(skip_serializing_if = "Option::is_none")]
859    served_certificate_chain: Option<Vec<String>>,
860    #[serde(skip_serializing_if = "Option::is_none")]
861    validated_certificate_chain: Option<Vec<String>>,
862    known_pins: Vec<String>,
863    #[serde(flatten)]
864    other: BTreeMap<String, serde_json::Value>,
865}
866
867impl HpkpRaw {
868    fn get_message(&self) -> String {
869        format!(
870            "Public key pinning validation failed for '{}'",
871            self.hostname
872        )
873    }
874
875    fn into_protocol(self) -> Hpkp {
876        Hpkp {
877            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
878            hostname: Annotated::new(self.hostname),
879            port: Annotated::from(self.port),
880            effective_expiration_date: Annotated::from(
881                self.effective_expiration_date.map(|d| d.to_rfc3339()),
882            ),
883            include_subdomains: Annotated::from(self.include_subdomains),
884            noted_hostname: Annotated::from(self.noted_hostname),
885            served_certificate_chain: Annotated::from(
886                self.served_certificate_chain
887                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
888            ),
889            validated_certificate_chain: Annotated::from(
890                self.validated_certificate_chain
891                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
892            ),
893            known_pins: Annotated::new(self.known_pins.into_iter().map(Annotated::from).collect()),
894            other: self
895                .other
896                .into_iter()
897                .map(|(k, v)| (k, Annotated::from(v)))
898                .collect(),
899        }
900    }
901
902    fn get_tags(&self) -> Tags {
903        let mut tags = vec![Annotated::new(TagEntry(
904            Annotated::new("hostname".to_owned()),
905            Annotated::new(self.hostname.clone()),
906        ))];
907
908        if let Some(port) = self.port {
909            tags.push(Annotated::new(TagEntry(
910                Annotated::new("port".to_owned()),
911                Annotated::new(port.to_string()),
912            )));
913        }
914
915        if let Some(include_subdomains) = self.include_subdomains {
916            tags.push(Annotated::new(TagEntry(
917                Annotated::new("include-subdomains".to_owned()),
918                Annotated::new(include_subdomains.to_string()),
919            )));
920        }
921
922        Tags(PairList::from(tags))
923    }
924
925    fn get_request(&self) -> Request {
926        Request {
927            url: Annotated::from(self.hostname.clone()),
928            ..Request::default()
929        }
930    }
931}
932
933/// Schema as defined in RFC7469, Section 3
934#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
935pub struct Hpkp {
936    /// Indicates the time the UA observed the Pin Validation failure.
937    pub date_time: Annotated<String>,
938    /// Hostname to which the UA made the original request that failed Pin Validation.
939    pub hostname: Annotated<String>,
940    /// The port to which the UA made the original request that failed Pin Validation.
941    pub port: Annotated<u64>,
942    /// Effective Expiration Date for the noted pins.
943    pub effective_expiration_date: Annotated<String>,
944    /// Indicates whether or not the UA has noted the includeSubDomains directive for the Known
945    /// Pinned Host.
946    pub include_subdomains: Annotated<bool>,
947
948    /// Indicates the hostname that the UA noted when it noted the Known Pinned Host.  This field
949    /// allows operators to understand why Pin Validation was performed for, e.g., foo.example.com
950    /// when the noted Known Pinned Host was example.com with includeSubDomains set.
951    pub noted_hostname: Annotated<String>,
952    /// The certificate chain, as served by the Known Pinned Host during TLS session setup.  It
953    /// is provided as an array of strings; each string pem1, ... pemN is the Privacy-Enhanced Mail
954    /// (PEM) representation of each X.509 certificate as described in [RFC7468].
955    ///
956    /// [RFC7468]: https://tools.ietf.org/html/rfc7468
957    pub served_certificate_chain: Annotated<Array<String>>,
958    /// The certificate chain, as constructed by the UA during certificate chain verification.
959    pub validated_certificate_chain: Annotated<Array<String>>,
960
961    /// Pins that the UA has noted for the Known Pinned Host.
962    // TODO: regex this string for 'pin-sha256="ABC123"' syntax
963    #[metastructure(required = true)]
964    pub known_pins: Annotated<Array<String>>,
965
966    #[metastructure(pii = "true", additional_properties)]
967    pub other: Object<Value>,
968}
969
970impl Hpkp {
971    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
972        let raw_hpkp = serde_json::from_slice::<HpkpRaw>(data)?;
973
974        event.logentry = Annotated::new(LogEntry::from(raw_hpkp.get_message()));
975        event.tags = Annotated::new(raw_hpkp.get_tags());
976        event.request = Annotated::new(raw_hpkp.get_request());
977        event.hpkp = Annotated::new(raw_hpkp.into_protocol());
978
979        Ok(())
980    }
981}
982
983/// Defines external, RFC-defined schema we accept, while `ExpectStaple` defines our own schema.
984///
985/// See `ExpectStaple` for meaning of fields.
986#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
987#[serde(rename_all = "kebab-case")]
988struct ExpectStapleReportRaw {
989    expect_staple_report: ExpectStapleRaw,
990}
991
992#[derive(Clone, Copy, Debug, PartialEq, Eq)]
993pub enum ExpectStapleResponseStatus {
994    Missing,
995    Provided,
996    ErrorResponse,
997    BadProducedAt,
998    NoMatchingResponse,
999    InvalidDate,
1000    ParseResponseError,
1001    ParseResponseDataError,
1002}
1003
1004relay_common::derive_fromstr_and_display!(ExpectStapleResponseStatus, InvalidSecurityError, {
1005    ExpectStapleResponseStatus::Missing => "MISSING",
1006    ExpectStapleResponseStatus::Provided => "PROVIDED",
1007    ExpectStapleResponseStatus::ErrorResponse => "ERROR_RESPONSE",
1008    ExpectStapleResponseStatus::BadProducedAt => "BAD_PRODUCED_AT",
1009    ExpectStapleResponseStatus::NoMatchingResponse => "NO_MATCHING_RESPONSE",
1010    ExpectStapleResponseStatus::InvalidDate => "INVALID_DATE",
1011    ExpectStapleResponseStatus::ParseResponseError => "PARSE_RESPONSE_ERROR",
1012    ExpectStapleResponseStatus::ParseResponseDataError => "PARSE_RESPONSE_DATA_ERROR",
1013});
1014
1015relay_common::impl_str_serde!(ExpectStapleResponseStatus, "an expect-ct response status");
1016
1017#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1018pub enum ExpectStapleCertStatus {
1019    Good,
1020    Revoked,
1021    Unknown,
1022}
1023
1024relay_common::derive_fromstr_and_display!(ExpectStapleCertStatus, InvalidSecurityError, {
1025    ExpectStapleCertStatus::Good => "GOOD",
1026    ExpectStapleCertStatus::Revoked => "REVOKED",
1027    ExpectStapleCertStatus::Unknown => "UNKNOWN",
1028});
1029
1030relay_common::impl_str_serde!(ExpectStapleCertStatus, "an expect-staple cert status");
1031
1032/// Inner (useful) part of a Expect Stable report as sent by a user agent ( browser)
1033#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
1034#[serde(rename_all = "kebab-case")]
1035struct ExpectStapleRaw {
1036    #[serde(skip_serializing_if = "Option::is_none")]
1037    date_time: Option<DateTime<Utc>>,
1038    hostname: String,
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    port: Option<i64>,
1041    #[serde(skip_serializing_if = "Option::is_none")]
1042    effective_expiration_date: Option<DateTime<Utc>>,
1043    #[serde(skip_serializing_if = "Option::is_none")]
1044    response_status: Option<ExpectStapleResponseStatus>,
1045    #[serde(skip_serializing_if = "Option::is_none")]
1046    ocsp_response: Option<Value>,
1047    #[serde(skip_serializing_if = "Option::is_none")]
1048    cert_status: Option<ExpectStapleCertStatus>,
1049    #[serde(skip_serializing_if = "Option::is_none")]
1050    served_certificate_chain: Option<Vec<String>>,
1051    #[serde(skip_serializing_if = "Option::is_none")]
1052    validated_certificate_chain: Option<Vec<String>>,
1053}
1054
1055impl ExpectStapleRaw {
1056    fn get_message(&self) -> String {
1057        format!("Expect-Staple failed for '{}'", self.hostname)
1058    }
1059
1060    fn into_protocol(self) -> ExpectStaple {
1061        ExpectStaple {
1062            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
1063            hostname: Annotated::from(self.hostname),
1064            port: Annotated::from(self.port),
1065            effective_expiration_date: Annotated::from(
1066                self.effective_expiration_date.map(|d| d.to_rfc3339()),
1067            ),
1068            response_status: Annotated::from(self.response_status.map(|rs| rs.to_string())),
1069            cert_status: Annotated::from(self.cert_status.map(|cs| cs.to_string())),
1070            served_certificate_chain: Annotated::from(
1071                self.served_certificate_chain
1072                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1073            ),
1074            validated_certificate_chain: Annotated::from(
1075                self.validated_certificate_chain
1076                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1077            ),
1078            ocsp_response: Annotated::from(self.ocsp_response),
1079        }
1080    }
1081
1082    fn get_culprit(&self) -> String {
1083        self.hostname.clone()
1084    }
1085
1086    fn get_tags(&self) -> Tags {
1087        let mut tags = vec![Annotated::new(TagEntry(
1088            Annotated::new("hostname".to_owned()),
1089            Annotated::new(self.hostname.clone()),
1090        ))];
1091
1092        if let Some(port) = self.port {
1093            tags.push(Annotated::new(TagEntry(
1094                Annotated::new("port".to_owned()),
1095                Annotated::new(port.to_string()),
1096            )));
1097        }
1098
1099        if let Some(response_status) = self.response_status {
1100            tags.push(Annotated::new(TagEntry(
1101                Annotated::new("response_status".to_owned()),
1102                Annotated::new(response_status.to_string()),
1103            )));
1104        }
1105
1106        if let Some(cert_status) = self.cert_status {
1107            tags.push(Annotated::new(TagEntry(
1108                Annotated::new("cert_status".to_owned()),
1109                Annotated::new(cert_status.to_string()),
1110            )));
1111        }
1112
1113        Tags(PairList::from(tags))
1114    }
1115
1116    fn get_request(&self) -> Request {
1117        Request {
1118            url: Annotated::from(self.hostname.clone()),
1119            ..Request::default()
1120        }
1121    }
1122}
1123
1124/// Represents an Expect Staple security report.
1125///
1126/// See <https://scotthelme.co.uk/ocsp-expect-staple/> for specification.
1127#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
1128pub struct ExpectStaple {
1129    date_time: Annotated<String>,
1130    hostname: Annotated<String>,
1131    port: Annotated<i64>,
1132    effective_expiration_date: Annotated<String>,
1133    response_status: Annotated<String>,
1134    cert_status: Annotated<String>,
1135    served_certificate_chain: Annotated<Array<String>>,
1136    validated_certificate_chain: Annotated<Array<String>>,
1137    ocsp_response: Annotated<Value>,
1138}
1139
1140impl ExpectStaple {
1141    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
1142        let raw_report = serde_json::from_slice::<ExpectStapleReportRaw>(data)?;
1143        let raw_expect_staple = raw_report.expect_staple_report;
1144
1145        event.logentry = Annotated::new(LogEntry::from(raw_expect_staple.get_message()));
1146        event.culprit = Annotated::new(raw_expect_staple.get_culprit());
1147        event.tags = Annotated::new(raw_expect_staple.get_tags());
1148        event.request = Annotated::new(raw_expect_staple.get_request());
1149        event.expectstaple = Annotated::new(raw_expect_staple.into_protocol());
1150
1151        Ok(())
1152    }
1153}
1154
1155#[derive(Clone, Debug, PartialEq, Eq)]
1156pub enum SecurityReportType {
1157    Csp,
1158    ExpectCt,
1159    ExpectStaple,
1160    Hpkp,
1161    Unsupported,
1162}
1163
1164impl SecurityReportType {
1165    /// Infers the type of a security report from its payload.
1166    ///
1167    /// This looks into the JSON payload and tries to infer the type from keys. If no report
1168    /// matches, an error is returned.
1169    pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
1170        #[derive(Deserialize)]
1171        #[serde(rename_all = "kebab-case")]
1172        struct SecurityReport {
1173            #[serde(rename = "type")]
1174            ty: Option<CspViolationType>,
1175            csp_report: Option<IgnoredAny>,
1176            known_pins: Option<IgnoredAny>,
1177            expect_staple_report: Option<IgnoredAny>,
1178            expect_ct_report: Option<IgnoredAny>,
1179        }
1180
1181        let helper: SecurityReport = serde_json::from_slice(data)?;
1182
1183        Ok(if helper.csp_report.is_some() {
1184            Some(SecurityReportType::Csp)
1185        } else if let Some(CspViolationType::CspViolation) = helper.ty {
1186            Some(SecurityReportType::Csp)
1187        } else if let Some(CspViolationType::Other) = helper.ty {
1188            Some(SecurityReportType::Unsupported)
1189        } else if helper.known_pins.is_some() {
1190            Some(SecurityReportType::Hpkp)
1191        } else if helper.expect_staple_report.is_some() {
1192            Some(SecurityReportType::ExpectStaple)
1193        } else if helper.expect_ct_report.is_some() {
1194            Some(SecurityReportType::ExpectCt)
1195        } else {
1196            None
1197        })
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use relay_protocol::assert_annotated_snapshot;
1205
1206    #[test]
1207    fn test_unsplit_uri() {
1208        assert_eq!(unsplit_uri("", ""), "");
1209        assert_eq!(unsplit_uri("data", ""), "data:");
1210        assert_eq!(unsplit_uri("data", "foo"), "data://foo");
1211        assert_eq!(unsplit_uri("http", ""), "http://");
1212        assert_eq!(unsplit_uri("http", "foo"), "http://foo");
1213    }
1214
1215    #[test]
1216    fn test_normalize_uri() {
1217        // Special handling for self URIs
1218        assert_eq!(normalize_uri(""), "'self'");
1219        assert_eq!(normalize_uri("self"), "'self'");
1220
1221        // Special handling for schema-only URIs
1222        assert_eq!(normalize_uri("data"), "data:");
1223        assert_eq!(normalize_uri("http"), "http://");
1224
1225        // URIs without port
1226        assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
1227        assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
1228        assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
1229        assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
1230
1231        // URIs with port
1232        assert_eq!(
1233            normalize_uri("http://notlocalhost:8000/"),
1234            "notlocalhost:8000"
1235        );
1236        assert_eq!(
1237            normalize_uri("http://notlocalhost:8000/lol.css"),
1238            "notlocalhost:8000"
1239        );
1240
1241        // Invalid URIs
1242        assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
1243    }
1244
1245    #[test]
1246    fn test_csp_basic() {
1247        let json = r#"{
1248            "csp-report": {
1249                "document-uri": "http://example.com",
1250                "violated-directive": "style-src cdn.example.com",
1251                "blocked-uri": "http://example.com/lol.css",
1252                "effective-directive": "style-src",
1253                "status-code": "200"
1254            }
1255        }"#;
1256
1257        let mut event = Event::default();
1258        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1259
1260        assert_annotated_snapshot!(Annotated::new(event), @r###"
1261        {
1262          "culprit": "style-src cdn.example.com",
1263          "logentry": {
1264            "formatted": "Blocked 'style' from 'example.com'"
1265          },
1266          "request": {
1267            "url": "http://example.com"
1268          },
1269          "tags": [
1270            [
1271              "effective-directive",
1272              "style-src"
1273            ],
1274            [
1275              "blocked-uri",
1276              "http://example.com/lol.css"
1277            ],
1278            [
1279              "blocked-host",
1280              "example.com"
1281            ]
1282          ],
1283          "csp": {
1284            "effective_directive": "style-src",
1285            "blocked_uri": "http://example.com/lol.css",
1286            "document_uri": "http://example.com",
1287            "status_code": 200,
1288            "violated_directive": "style-src cdn.example.com"
1289          }
1290        }
1291        "###);
1292    }
1293
1294    #[test]
1295    fn test_csp_coerce_blocked_uri_if_missing() {
1296        let json = r#"{
1297            "csp-report": {
1298                "document-uri": "http://example.com",
1299                "effective-directive": "script-src"
1300            }
1301        }"#;
1302
1303        let mut event = Event::default();
1304        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1305
1306        assert_annotated_snapshot!(Annotated::new(event), @r###"
1307        {
1308          "culprit": "",
1309          "logentry": {
1310            "formatted": "Blocked unsafe (eval() or inline) 'script'"
1311          },
1312          "request": {
1313            "url": "http://example.com"
1314          },
1315          "tags": [
1316            [
1317              "effective-directive",
1318              "script-src"
1319            ],
1320            [
1321              "blocked-uri",
1322              "self"
1323            ]
1324          ],
1325          "csp": {
1326            "effective_directive": "script-src",
1327            "blocked_uri": "self",
1328            "document_uri": "http://example.com",
1329            "violated_directive": ""
1330          }
1331        }
1332        "###);
1333    }
1334
1335    #[test]
1336    fn test_csp_msdn() {
1337        let json = r#"{
1338            "csp-report": {
1339                "document-uri": "https://example.com/foo/bar",
1340                "referrer": "https://www.google.com/",
1341                "violated-directive": "default-src self",
1342                "original-policy": "default-src self; report-uri /csp-hotline.php",
1343                "blocked-uri": "http://evilhackerscripts.com"
1344            }
1345        }"#;
1346
1347        let mut event = Event::default();
1348        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1349
1350        assert_annotated_snapshot!(Annotated::new(event), @r###"
1351        {
1352          "culprit": "default-src self",
1353          "logentry": {
1354            "formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
1355          },
1356          "request": {
1357            "url": "https://example.com/foo/bar",
1358            "headers": [
1359              [
1360                "Referer",
1361                "https://www.google.com/"
1362              ]
1363            ]
1364          },
1365          "tags": [
1366            [
1367              "effective-directive",
1368              "default-src"
1369            ],
1370            [
1371              "blocked-uri",
1372              "http://evilhackerscripts.com"
1373            ],
1374            [
1375              "blocked-host",
1376              "evilhackerscripts.com"
1377            ]
1378          ],
1379          "csp": {
1380            "effective_directive": "default-src",
1381            "blocked_uri": "http://evilhackerscripts.com",
1382            "document_uri": "https://example.com/foo/bar",
1383            "original_policy": "default-src self; report-uri /csp-hotline.php",
1384            "referrer": "https://www.google.com/",
1385            "violated_directive": "default-src self"
1386          }
1387        }
1388        "###);
1389    }
1390
1391    #[test]
1392    fn test_csp_real() {
1393        let json = r#"{
1394            "csp-report": {
1395                "document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
1396                "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1397                "violated-directive": "script-src",
1398                "effective-directive": "script-src",
1399                "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",
1400                "disposition": "enforce",
1401                "blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
1402                "line-number": 24,
1403                "column-number": 66270,
1404                "source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1405                "status-code": 0,
1406                "script-sample": ""
1407            }
1408        }"#;
1409
1410        let mut event = Event::default();
1411        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1412
1413        assert_annotated_snapshot!(Annotated::new(event), @r###"
1414        {
1415          "culprit": "script-src",
1416          "logentry": {
1417            "formatted": "Blocked 'script' from 'baddomain.com'"
1418          },
1419          "request": {
1420            "url": "https://sentry.io/sentry/csp/issues/88513416/",
1421            "headers": [
1422              [
1423                "Referer",
1424                "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
1425              ]
1426            ]
1427          },
1428          "tags": [
1429            [
1430              "effective-directive",
1431              "script-src"
1432            ],
1433            [
1434              "blocked-uri",
1435              "http://baddomain.com/test.js?_=1515535030116"
1436            ],
1437            [
1438              "blocked-host",
1439              "baddomain.com"
1440            ]
1441          ],
1442          "csp": {
1443            "effective_directive": "script-src",
1444            "blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
1445            "document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
1446            "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",
1447            "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1448            "status_code": 0,
1449            "violated_directive": "script-src",
1450            "source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1451            "line_number": 24,
1452            "column_number": 66270,
1453            "script_sample": "",
1454            "disposition": "enforce"
1455          }
1456        }
1457        "###);
1458    }
1459
1460    #[test]
1461    fn test_csp_sample_alias() {
1462        let json = r#"{
1463            "csp-report": {
1464                "document-uri": "http://example.com/foo",
1465                "violated-directive": "style-src http://cdn.example.com",
1466                "effective-directive": "style-src",
1467                "sample": "console.log(\"lo\")"
1468            }
1469    }"#;
1470
1471        let mut event = Event::default();
1472        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1473        assert_annotated_snapshot!(event.csp, @r#"
1474        {
1475          "effective_directive": "style-src",
1476          "blocked_uri": "self",
1477          "document_uri": "http://example.com/foo",
1478          "violated_directive": "style-src http://cdn.example.com",
1479          "script_sample": "console.log(\"lo\")"
1480        }
1481        "#);
1482    }
1483
1484    #[test]
1485    fn test_csp_culprit_0() {
1486        let json = r#"{
1487            "csp-report": {
1488                "document-uri": "http://example.com/foo",
1489                "violated-directive": "style-src http://cdn.example.com",
1490                "effective-directive": "style-src"
1491            }
1492        }"#;
1493
1494        let mut event = Event::default();
1495        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1496        insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://cdn.example.com""###);
1497    }
1498
1499    #[test]
1500    fn test_csp_culprit_1() {
1501        let json = r#"{
1502            "csp-report": {
1503                "document-uri": "http://example.com/foo",
1504                "violated-directive": "style-src cdn.example.com",
1505                "effective-directive": "style-src"
1506            }
1507        }"#;
1508
1509        let mut event = Event::default();
1510        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1511        insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
1512    }
1513
1514    #[test]
1515    fn test_csp_culprit_2() {
1516        let json = r#"{
1517            "csp-report": {
1518                "document-uri": "https://example.com/foo",
1519                "violated-directive": "style-src cdn.example.com",
1520                "effective-directive": "style-src"
1521            }
1522        }"#;
1523
1524        let mut event = Event::default();
1525        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1526        insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
1527    }
1528
1529    #[test]
1530    fn test_csp_culprit_3() {
1531        let json = r#"{
1532            "csp-report": {
1533                "document-uri": "http://example.com/foo",
1534                "violated-directive": "style-src https://cdn.example.com",
1535                "effective-directive": "style-src"
1536            }
1537        }"#;
1538
1539        let mut event = Event::default();
1540        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1541        insta::assert_debug_snapshot!(event.culprit, @r###""style-src https://cdn.example.com""###);
1542    }
1543
1544    #[test]
1545    fn test_csp_culprit_4() {
1546        let json = r#"{
1547            "csp-report": {
1548                "document-uri": "http://example.com/foo",
1549                "violated-directive": "style-src http://example.com",
1550                "effective-directive": "style-src"
1551            }
1552        }"#;
1553
1554        let mut event = Event::default();
1555        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1556        insta::assert_debug_snapshot!(event.culprit, @r###""style-src 'self'""###);
1557    }
1558
1559    #[test]
1560    fn test_csp_culprit_5() {
1561        let json = r#"{
1562            "csp-report": {
1563                "document-uri": "http://example.com/foo",
1564                "violated-directive": "style-src http://example2.com example.com",
1565                "effective-directive": "style-src"
1566            }
1567        }"#;
1568
1569        let mut event = Event::default();
1570        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1571        insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://example2.com 'self'""###);
1572    }
1573
1574    #[test]
1575    fn test_csp_culprit_uri_without_scheme() {
1576        // Not sure if this is a real-world example, but let's cover it anyway
1577        let json = r#"{
1578            "csp-report": {
1579                "document-uri": "example.com",
1580                "violated-directive": "style-src example2.com"
1581            }
1582        }"#;
1583
1584        let mut event = Event::default();
1585        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1586        insta::assert_debug_snapshot!(event.culprit, @r###""style-src example2.com""###);
1587    }
1588
1589    #[test]
1590    fn test_csp_tags_stripe() {
1591        // This is a regression test for potential PII in stripe URLs. PII stripping used to skip
1592        // report interfaces, which is why there is special handling.
1593
1594        let json = r#"{
1595            "csp-report": {
1596                "document-uri": "https://example.com",
1597                "blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
1598                "effective-directive": "script-src"
1599            }
1600        }"#;
1601
1602        let mut event = Event::default();
1603        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1604        insta::assert_debug_snapshot!(event.tags, @r###"
1605        Tags(
1606            PairList(
1607                [
1608                    TagEntry(
1609                        "effective-directive",
1610                        "script-src",
1611                    ),
1612                    TagEntry(
1613                        "blocked-uri",
1614                        "https://api.stripe.com/v1/tokens",
1615                    ),
1616                    TagEntry(
1617                        "blocked-host",
1618                        "api.stripe.com",
1619                    ),
1620                ],
1621            ),
1622        )
1623        "###);
1624    }
1625
1626    #[test]
1627    fn test_csp_get_message_0() {
1628        let json = r#"{
1629            "csp-report": {
1630                "document-uri": "http://example.com/foo",
1631                "effective-directive": "img-src",
1632                "blocked-uri": "http://google.com/foo"
1633            }
1634        }"#;
1635
1636        let mut event = Event::default();
1637        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1638        let message = &event.logentry.value().unwrap().formatted;
1639        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'image' from 'google.com'""###);
1640    }
1641
1642    #[test]
1643    fn test_csp_get_message_1() {
1644        let json = r#"{
1645            "csp-report": {
1646                "document-uri": "http://example.com/foo",
1647                "effective-directive": "style-src",
1648                "blocked-uri": ""
1649            }
1650        }"#;
1651
1652        let mut event = Event::default();
1653        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1654        let message = &event.logentry.value().unwrap().formatted;
1655        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked inline 'style'""###);
1656    }
1657
1658    #[test]
1659    fn test_csp_get_message_2() {
1660        let json = r#"{
1661            "csp-report": {
1662                "document-uri": "http://example.com/foo",
1663                "effective-directive": "script-src",
1664                "blocked-uri": "",
1665                "violated-directive": "script-src 'unsafe-inline'"
1666            }
1667        }"#;
1668
1669        let mut event = Event::default();
1670        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1671        let message = &event.logentry.value().unwrap().formatted;
1672        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe inline 'script'""###);
1673    }
1674
1675    #[test]
1676    fn test_csp_get_message_3() {
1677        let json = r#"{
1678            "csp-report": {
1679                "document-uri": "http://example.com/foo",
1680                "effective-directive": "script-src",
1681                "blocked-uri": "",
1682                "violated-directive": "script-src 'unsafe-eval'"
1683            }
1684        }"#;
1685
1686        let mut event = Event::default();
1687        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1688        let message = &event.logentry.value().unwrap().formatted;
1689        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe eval() 'script'""###);
1690    }
1691
1692    #[test]
1693    fn test_csp_get_message_4() {
1694        let json = r#"{
1695            "csp-report": {
1696                "document-uri": "http://example.com/foo",
1697                "effective-directive": "script-src",
1698                "blocked-uri": "",
1699                "violated-directive": "script-src example.com"
1700            }
1701        }"#;
1702
1703        let mut event = Event::default();
1704        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1705        let message = &event.logentry.value().unwrap().formatted;
1706        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe (eval() or inline) 'script'""###);
1707    }
1708
1709    #[test]
1710    fn test_csp_get_message_5() {
1711        let json = r#"{
1712            "csp-report": {
1713                "document-uri": "http://example.com/foo",
1714                "effective-directive": "script-src",
1715                "blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
1716            }
1717        }"#;
1718
1719        let mut event = Event::default();
1720        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1721        let message = &event.logentry.value().unwrap().formatted;
1722        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1723    }
1724
1725    #[test]
1726    fn test_csp_get_message_6() {
1727        let json = r#"{
1728            "csp-report": {
1729                "document-uri": "http://example.com/foo",
1730                "effective-directive": "script-src",
1731                "blocked-uri": "data"
1732            }
1733        }"#;
1734
1735        let mut event = Event::default();
1736        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1737        let message = &event.logentry.value().unwrap().formatted;
1738        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1739    }
1740
1741    #[test]
1742    fn test_csp_get_message_7() {
1743        let json = r#"{
1744            "csp-report": {
1745                "document-uri": "http://example.com/foo",
1746                "effective-directive": "style-src-elem",
1747                "blocked-uri": "http://fonts.google.com/foo"
1748            }
1749        }"#;
1750
1751        let mut event = Event::default();
1752        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1753        let message = &event.logentry.value().unwrap().formatted;
1754        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'fonts.google.com'""###);
1755    }
1756
1757    #[test]
1758    fn test_csp_get_message_8() {
1759        let json = r#"{
1760            "csp-report": {
1761                "document-uri": "http://example.com/foo",
1762                "effective-directive": "script-src-elem",
1763                "blocked-uri": "http://cdn.ajaxapis.com/foo"
1764            }
1765        }"#;
1766
1767        let mut event = Event::default();
1768        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1769        let message = &event.logentry.value().unwrap().formatted;
1770        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'cdn.ajaxapis.com'""###);
1771    }
1772
1773    #[test]
1774    fn test_csp_get_message_9() {
1775        let json = r#"{
1776            "csp-report": {
1777                "document-uri": "http://notlocalhost:8000/",
1778                "effective-directive": "style-src",
1779                "blocked-uri": "http://notlocalhost:8000/lol.css"
1780            }
1781        }"#;
1782
1783        let mut event = Event::default();
1784        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1785        let message = &event.logentry.value().unwrap().formatted;
1786        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'notlocalhost:8000'""###);
1787    }
1788
1789    #[test]
1790    fn test_expectct_basic() {
1791        let json = r#"{
1792            "expect-ct-report": {
1793                "date-time": "2014-04-06T13:00:50Z",
1794                "hostname": "www.example.com",
1795                "port": 443,
1796                "scheme": "https",
1797                "effective-expiration-date": "2014-05-01T12:40:50Z",
1798                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1799                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1800                "scts": [
1801                    {
1802                        "version": 1,
1803                        "status": "invalid",
1804                        "source": "embedded",
1805                        "serialized_sct": "ABCD=="
1806                    }
1807                ],
1808                "failure-mode": "enforce",
1809                "test-report": false
1810            }
1811        }"#;
1812
1813        let mut event = Event::default();
1814        ExpectCt::apply_to_event(json.as_bytes(), &mut event).unwrap();
1815        assert_annotated_snapshot!(Annotated::new(event), @r###"
1816        {
1817          "culprit": "www.example.com",
1818          "logentry": {
1819            "formatted": "Expect-CT failed for 'www.example.com'"
1820          },
1821          "request": {
1822            "url": "www.example.com"
1823          },
1824          "tags": [
1825            [
1826              "hostname",
1827              "www.example.com"
1828            ],
1829            [
1830              "port",
1831              "443"
1832            ]
1833          ],
1834          "expectct": {
1835            "date_time": "2014-04-06T13:00:50+00:00",
1836            "hostname": "www.example.com",
1837            "port": 443,
1838            "scheme": "https",
1839            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1840            "served_certificate_chain": [
1841              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1842            ],
1843            "validated_certificate_chain": [
1844              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1845            ],
1846            "scts": [
1847              {
1848                "version": 1,
1849                "status": "invalid",
1850                "source": "embedded",
1851                "serialized_sct": "ABCD=="
1852              }
1853            ],
1854            "failure_mode": "enforce",
1855            "test_report": false
1856          }
1857        }
1858        "###);
1859    }
1860
1861    #[test]
1862    fn test_expectct_invalid() {
1863        let json = r#"{
1864            "hostname": "www.example.com",
1865            "date_time": "Not an RFC3339 datetime"
1866        }"#;
1867
1868        let mut event = Event::default();
1869        ExpectCt::apply_to_event(json.as_bytes(), &mut event)
1870            .expect_err("date_time should fail to parse");
1871    }
1872
1873    #[test]
1874    fn test_expectstaple_basic() {
1875        let json = r#"{
1876            "expect-staple-report": {
1877                "date-time": "2014-04-06T13:00:50Z",
1878                "hostname": "www.example.com",
1879                "port": 443,
1880                "response-status": "ERROR_RESPONSE",
1881                "cert-status": "REVOKED",
1882                "effective-expiration-date": "2014-05-01T12:40:50Z",
1883                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1884                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
1885            }
1886        }"#;
1887
1888        let mut event = Event::default();
1889        ExpectStaple::apply_to_event(json.as_bytes(), &mut event).unwrap();
1890        assert_annotated_snapshot!(Annotated::new(event), @r###"
1891        {
1892          "culprit": "www.example.com",
1893          "logentry": {
1894            "formatted": "Expect-Staple failed for 'www.example.com'"
1895          },
1896          "request": {
1897            "url": "www.example.com"
1898          },
1899          "tags": [
1900            [
1901              "hostname",
1902              "www.example.com"
1903            ],
1904            [
1905              "port",
1906              "443"
1907            ],
1908            [
1909              "response_status",
1910              "ERROR_RESPONSE"
1911            ],
1912            [
1913              "cert_status",
1914              "REVOKED"
1915            ]
1916          ],
1917          "expectstaple": {
1918            "date_time": "2014-04-06T13:00:50+00:00",
1919            "hostname": "www.example.com",
1920            "port": 443,
1921            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1922            "response_status": "ERROR_RESPONSE",
1923            "cert_status": "REVOKED",
1924            "served_certificate_chain": [
1925              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1926            ],
1927            "validated_certificate_chain": [
1928              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1929            ]
1930          }
1931        }
1932        "###);
1933    }
1934
1935    #[test]
1936    fn test_hpkp_basic() {
1937        let json = r#"{
1938            "date-time": "2014-04-06T13:00:50Z",
1939            "hostname": "example.com",
1940            "port": 443,
1941            "effective-expiration-date": "2014-05-01T12:40:50Z",
1942            "include-subdomains": false,
1943            "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1944            "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1945            "known-pins": ["pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""]
1946        }"#;
1947
1948        let mut event = Event::default();
1949        Hpkp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1950        assert_annotated_snapshot!(Annotated::new(event), @r###"
1951        {
1952          "logentry": {
1953            "formatted": "Public key pinning validation failed for 'example.com'"
1954          },
1955          "request": {
1956            "url": "example.com"
1957          },
1958          "tags": [
1959            [
1960              "hostname",
1961              "example.com"
1962            ],
1963            [
1964              "port",
1965              "443"
1966            ],
1967            [
1968              "include-subdomains",
1969              "false"
1970            ]
1971          ],
1972          "hpkp": {
1973            "date_time": "2014-04-06T13:00:50+00:00",
1974            "hostname": "example.com",
1975            "port": 443,
1976            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1977            "include_subdomains": false,
1978            "served_certificate_chain": [
1979              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1980            ],
1981            "validated_certificate_chain": [
1982              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1983            ],
1984            "known_pins": [
1985              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
1986            ]
1987          }
1988        }
1989        "###);
1990    }
1991
1992    #[test]
1993    fn test_security_report_type_deserializer_recognizes_csp_reports() {
1994        let csp_report_text = r#"{
1995            "csp-report": {
1996                "document-uri": "https://example.com/foo/bar",
1997                "referrer": "https://www.google.com/",
1998                "violated-directive": "default-src self",
1999                "original-policy": "default-src self; report-uri /csp-hotline.php",
2000                "blocked-uri": "http://evilhackerscripts.com"
2001            }
2002        }"#;
2003
2004        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
2005        assert_eq!(report_type, Some(SecurityReportType::Csp));
2006    }
2007
2008    #[test]
2009    fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
2010        let csp_report_text = r#"{
2011          "age":0,
2012          "body":{
2013            "blockedURL":"https://example.com/tst/media/7_del.png",
2014            "disposition":"enforce",
2015            "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2016            "effectiveDirective":"img-src",
2017            "lineNumber":9,
2018            "originalPolicy":"default-src 'none'; report-to endpoint-csp;",
2019            "referrer":"https://example.com/test229/",
2020            "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2021            "statusCode":0
2022            },
2023          "type":"csp-violation",
2024          "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2025          "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"
2026        }"#;
2027
2028        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
2029        assert_eq!(report_type, Some(SecurityReportType::Csp));
2030    }
2031
2032    #[test]
2033    fn test_security_report_type_deserializer_recognizes_expect_ct_reports() {
2034        let expect_ct_report_text = r#"{
2035            "expect-ct-report": {
2036                "date-time": "2014-04-06T13:00:50Z",
2037                "hostname": "www.example.com",
2038                "port": 443,
2039                "effective-expiration-date": "2014-05-01T12:40:50Z",
2040                "served-certificate-chain": [
2041                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2042                ],
2043                "validated-certificate-chain": [
2044                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2045                ],
2046                "scts": [
2047                    {
2048                        "version": 1,
2049                        "status": "invalid",
2050                        "source": "embedded",
2051                        "serialized_sct": "ABCD=="
2052                    }
2053                ]
2054            }
2055        }"#;
2056
2057        let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
2058        assert_eq!(report_type, Some(SecurityReportType::ExpectCt));
2059    }
2060
2061    #[test]
2062    fn test_security_report_type_deserializer_recognizes_expect_staple_reports() {
2063        let expect_staple_report_text = r#"{
2064             "expect-staple-report": {
2065                "date-time": "2014-04-06T13:00:50Z",
2066                "hostname": "www.example.com",
2067                "port": 443,
2068                "response-status": "ERROR_RESPONSE",
2069                "cert-status": "REVOKED",
2070                "effective-expiration-date": "2014-05-01T12:40:50Z",
2071                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
2072                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
2073            }
2074        }"#;
2075        let report_type =
2076            SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
2077        assert_eq!(report_type, Some(SecurityReportType::ExpectStaple));
2078    }
2079
2080    #[test]
2081    fn test_security_report_type_deserializer_recognizes_hpkp_reports() {
2082        let hpkp_report_text = r#"{
2083            "date-time": "2014-04-06T13:00:50Z",
2084            "hostname": "www.example.com",
2085            "port": 443,
2086            "effective-expiration-date": "2014-05-01T12:40:50Z",
2087            "include-subdomains": false,
2088            "served-certificate-chain": [
2089              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2090            ],
2091            "validated-certificate-chain": [
2092              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2093            ],
2094            "known-pins": [
2095              "pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
2096              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
2097            ]
2098          }"#;
2099
2100        let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
2101        assert_eq!(report_type, Some(SecurityReportType::Hpkp));
2102    }
2103
2104    #[test]
2105    fn test_effective_directive_from_violated_directive_single() {
2106        // Example from Firefox:
2107        let csp_raw: CspRaw =
2108            serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
2109        assert!(matches!(
2110            csp_raw.effective_directive(),
2111            Ok(CspDirective::DefaultSrc)
2112        ));
2113    }
2114}