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    )]
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            if let Some(index) = uri.find(&['#', '?'][..]) {
400                uri.truncate(index);
401            }
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            if let ("http" | "https", Some(host)) = (url.scheme(), url.host_str()) {
481                tags.push(Annotated::new(TagEntry(
482                    Annotated::new("blocked-host".to_owned()),
483                    Annotated::new(host.to_owned()),
484                )));
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/// Defines external, RFC-defined schema we accept, while `Csp` defines our own schema.
511///
512/// See `Csp` for meaning of fields.
513#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
514#[serde(rename_all = "kebab-case")]
515struct CspReportRaw {
516    csp_report: CspRaw,
517}
518
519#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
520#[serde(untagged)]
521enum CspVariant {
522    Csp {
523        #[serde(rename = "csp-report")]
524        csp_report: CspRaw,
525    },
526    /// Defines CSP report sent through the [Reporting API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API).
527    ///
528    /// This contains the [body](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody)
529    /// with actual report. We currently ignore the additional fields.
530    /// Reporting API has [slightly different format](https://csplite.com/csp66/#sample-violation-report) for the CSP report body,
531    /// but the biggest difference that browser sends the CSP reports in batches.
532    CspViolation { body: CspRaw },
533}
534
535/// The type of the CSP report which comes through the Reporting API.
536#[derive(Clone, Debug, PartialEq, Deserialize)]
537#[serde(rename_all = "kebab-case")]
538enum CspViolationType {
539    CspViolation,
540    #[serde(other)]
541    Other,
542}
543
544/// Models the content of a CSP report.
545///
546/// Note this models the older CSP reports (report-uri policy directive).
547/// The new CSP reports (using report-to policy directive) are different.
548///
549/// NOTE: This is the structure used inside the Event (serialization is based on Annotated
550/// infrastructure). We also use a version of this structure to deserialize from raw JSON
551/// via serde.
552///
553///
554/// See <https://www.w3.org/TR/CSP3/>
555#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
556pub struct Csp {
557    /// The directive whose enforcement caused the violation.
558    #[metastructure(pii = "true")]
559    pub effective_directive: Annotated<String>,
560    /// The URI of the resource that was blocked from loading by the Content Security Policy.
561    #[metastructure(pii = "true")]
562    pub blocked_uri: Annotated<String>,
563    /// The URI of the document in which the violation occurred.
564    #[metastructure(pii = "true")]
565    pub document_uri: Annotated<String>,
566    /// The original policy as specified by the Content-Security-Policy HTTP header.
567    pub original_policy: Annotated<String>,
568    /// The referrer of the document in which the violation occurred.
569    #[metastructure(pii = "true")]
570    pub referrer: Annotated<String>,
571    /// The HTTP status code of the resource on which the global object was instantiated.
572    pub status_code: Annotated<u64>,
573    /// The name of the policy section that was violated.
574    pub violated_directive: Annotated<String>,
575    /// The URL of the resource where the violation occurred.
576    #[metastructure(pii = "maybe")]
577    pub source_file: Annotated<String>,
578    /// The line number in source-file on which the violation occurred.
579    pub line_number: Annotated<u64>,
580    /// The column number in source-file on which the violation occurred.
581    pub column_number: Annotated<u64>,
582    /// The first 40 characters of the inline script, event handler, or style that caused the
583    /// violation.
584    pub script_sample: Annotated<String>,
585    /// Policy disposition (enforce or report).
586    pub disposition: Annotated<String>,
587    /// Additional arbitrary fields for forwards compatibility.
588    #[metastructure(pii = "true", additional_properties)]
589    pub other: Object<Value>,
590}
591
592impl Csp {
593    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
594        let variant = serde_json::from_slice::<CspVariant>(data)?;
595        match variant {
596            CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
597            CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
598        }
599
600        Ok(())
601    }
602
603    fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
604        let effective_directive = raw_csp
605            .effective_directive()
606            .map_err(serde::de::Error::custom)?;
607
608        event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
609        event.culprit = Annotated::new(raw_csp.get_culprit());
610        event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
611        event.request = Annotated::new(raw_csp.get_request());
612        event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
613
614        Ok(())
615    }
616}
617
618#[derive(Clone, Copy, Debug, PartialEq, Eq)]
619enum ExpectCtStatus {
620    Unknown,
621    Valid,
622    Invalid,
623}
624
625relay_common::derive_fromstr_and_display!(ExpectCtStatus, InvalidSecurityError, {
626    ExpectCtStatus::Unknown => "unknown",
627    ExpectCtStatus::Valid => "valid",
628    ExpectCtStatus::Invalid => "invalid",
629});
630
631relay_common::impl_str_serde!(ExpectCtStatus, "an expect-ct status");
632
633#[derive(Clone, Copy, Debug, PartialEq, Eq)]
634enum ExpectCtSource {
635    TlsExtension,
636    Ocsp,
637    Embedded,
638}
639
640relay_common::derive_fromstr_and_display!(ExpectCtSource, InvalidSecurityError, {
641            ExpectCtSource::TlsExtension => "tls-extension",
642            ExpectCtSource::Ocsp => "ocsp",
643            ExpectCtSource::Embedded => "embedded",
644});
645
646relay_common::impl_str_serde!(ExpectCtSource, "an expect-ct source");
647
648#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
649struct SingleCertificateTimestampRaw {
650    version: Option<i64>,
651    status: Option<ExpectCtStatus>,
652    source: Option<ExpectCtSource>,
653    serialized_sct: Option<String>, // NOT kebab-case!
654}
655
656impl SingleCertificateTimestampRaw {
657    fn into_protocol(self) -> SingleCertificateTimestamp {
658        SingleCertificateTimestamp {
659            version: Annotated::from(self.version),
660            status: Annotated::from(self.status.map(|s| s.to_string())),
661            source: Annotated::from(self.source.map(|s| s.to_string())),
662            serialized_sct: Annotated::from(self.serialized_sct),
663        }
664    }
665}
666
667#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
668#[serde(rename_all = "kebab-case")]
669struct ExpectCtRaw {
670    #[serde(with = "serde_date_time_3339")]
671    date_time: Option<DateTime<Utc>>,
672    hostname: String,
673    port: Option<i64>,
674    scheme: Option<String>,
675    #[serde(with = "serde_date_time_3339")]
676    effective_expiration_date: Option<DateTime<Utc>>,
677    served_certificate_chain: Option<Vec<String>>,
678    validated_certificate_chain: Option<Vec<String>>,
679    scts: Option<Vec<SingleCertificateTimestampRaw>>,
680    failure_mode: Option<String>,
681    test_report: Option<bool>,
682}
683
684mod serde_date_time_3339 {
685    use serde::de::Visitor;
686
687    use super::*;
688
689    pub fn serialize<S>(date_time: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
690    where
691        S: serde::Serializer,
692    {
693        match date_time {
694            None => serializer.serialize_none(),
695            Some(d) => serializer.serialize_str(&d.to_rfc3339()),
696        }
697    }
698
699    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
700    where
701        D: serde::Deserializer<'de>,
702    {
703        struct DateTimeVisitor;
704
705        impl Visitor<'_> for DateTimeVisitor {
706            type Value = Option<DateTime<Utc>>;
707
708            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
709                formatter.write_str("expected a date-time in RFC 3339 format")
710            }
711
712            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
713            where
714                E: serde::de::Error,
715            {
716                DateTime::parse_from_rfc3339(s)
717                    .map(|d| Some(d.with_timezone(&Utc)))
718                    .map_err(serde::de::Error::custom)
719            }
720
721            fn visit_none<E>(self) -> Result<Self::Value, E>
722            where
723                E: Error,
724            {
725                Ok(None)
726            }
727        }
728
729        deserializer.deserialize_any(DateTimeVisitor)
730    }
731}
732
733impl ExpectCtRaw {
734    fn get_message(&self) -> String {
735        format!("Expect-CT failed for '{}'", self.hostname)
736    }
737
738    fn into_protocol(self) -> ExpectCt {
739        ExpectCt {
740            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
741            hostname: Annotated::from(self.hostname),
742            port: Annotated::from(self.port),
743            scheme: Annotated::from(self.scheme),
744            effective_expiration_date: Annotated::from(
745                self.effective_expiration_date.map(|d| d.to_rfc3339()),
746            ),
747            served_certificate_chain: Annotated::new(
748                self.served_certificate_chain
749                    .map(|s| s.into_iter().map(Annotated::from).collect())
750                    .unwrap_or_default(),
751            ),
752
753            validated_certificate_chain: Annotated::new(
754                self.validated_certificate_chain
755                    .map(|v| v.into_iter().map(Annotated::from).collect())
756                    .unwrap_or_default(),
757            ),
758            scts: Annotated::from(self.scts.map(|scts| {
759                scts.into_iter()
760                    .map(|elm| Annotated::from(elm.into_protocol()))
761                    .collect()
762            })),
763            failure_mode: Annotated::from(self.failure_mode),
764            test_report: Annotated::from(self.test_report),
765        }
766    }
767
768    fn get_culprit(&self) -> String {
769        self.hostname.clone()
770    }
771
772    fn get_tags(&self) -> Tags {
773        let mut tags = vec![Annotated::new(TagEntry(
774            Annotated::new("hostname".to_owned()),
775            Annotated::new(self.hostname.clone()),
776        ))];
777
778        if let Some(port) = self.port {
779            tags.push(Annotated::new(TagEntry(
780                Annotated::new("port".to_owned()),
781                Annotated::new(port.to_string()),
782            )));
783        }
784
785        Tags(PairList::from(tags))
786    }
787
788    fn get_request(&self) -> Request {
789        Request {
790            url: Annotated::from(self.hostname.clone()),
791            ..Request::default()
792        }
793    }
794}
795
796#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
797#[serde(rename_all = "kebab-case")]
798struct ExpectCtReportRaw {
799    expect_ct_report: ExpectCtRaw,
800}
801
802/// Object used in ExpectCt reports
803///
804/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>.
805#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
806pub struct SingleCertificateTimestamp {
807    pub version: Annotated<i64>,
808    pub status: Annotated<String>,
809    pub source: Annotated<String>,
810    pub serialized_sct: Annotated<String>,
811}
812
813/// Expect CT security report sent by user agent (browser).
814///
815/// See <https://tools.ietf.org/html/draft-ietf-httpbis-expect-ct-07#section-3.1>
816#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
817pub struct ExpectCt {
818    /// Date time in rfc3339 format YYYY-MM-DDTHH:MM:DD{.FFFFFF}(Z|+/-HH:MM)
819    /// UTC time that the UA observed the CT compliance failure
820    pub date_time: Annotated<String>,
821    /// The hostname to which the UA made the original request that failed the CT compliance check.
822    pub hostname: Annotated<String>,
823    pub port: Annotated<i64>,
824    pub scheme: Annotated<String>,
825    /// Date time in rfc3339 format
826    pub effective_expiration_date: Annotated<String>,
827    pub served_certificate_chain: Annotated<Array<String>>,
828    pub validated_certificate_chain: Annotated<Array<String>>,
829    pub scts: Annotated<Array<SingleCertificateTimestamp>>,
830    pub failure_mode: Annotated<String>,
831    pub test_report: Annotated<bool>,
832}
833
834impl ExpectCt {
835    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
836        let raw_report = serde_json::from_slice::<ExpectCtReportRaw>(data)?;
837        let raw_expect_ct = raw_report.expect_ct_report;
838
839        event.logentry = Annotated::new(LogEntry::from(raw_expect_ct.get_message()));
840        event.culprit = Annotated::new(raw_expect_ct.get_culprit());
841        event.tags = Annotated::new(raw_expect_ct.get_tags());
842        event.request = Annotated::new(raw_expect_ct.get_request());
843        event.expectct = Annotated::new(raw_expect_ct.into_protocol());
844
845        Ok(())
846    }
847}
848
849/// Defines external, RFC-defined schema we accept, while `Hpkp` defines our own schema.
850///
851/// See `Hpkp` for meaning of fields.
852#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
853#[serde(rename_all = "kebab-case")]
854struct HpkpRaw {
855    #[serde(skip_serializing_if = "Option::is_none")]
856    date_time: Option<DateTime<Utc>>,
857    hostname: String,
858    #[serde(skip_serializing_if = "Option::is_none")]
859    port: Option<u64>,
860    #[serde(skip_serializing_if = "Option::is_none")]
861    effective_expiration_date: Option<DateTime<Utc>>,
862    #[serde(skip_serializing_if = "Option::is_none")]
863    include_subdomains: Option<bool>,
864    #[serde(skip_serializing_if = "Option::is_none")]
865    noted_hostname: Option<String>,
866    #[serde(skip_serializing_if = "Option::is_none")]
867    served_certificate_chain: Option<Vec<String>>,
868    #[serde(skip_serializing_if = "Option::is_none")]
869    validated_certificate_chain: Option<Vec<String>>,
870    known_pins: Vec<String>,
871    #[serde(flatten)]
872    other: BTreeMap<String, serde_json::Value>,
873}
874
875impl HpkpRaw {
876    fn get_message(&self) -> String {
877        format!(
878            "Public key pinning validation failed for '{}'",
879            self.hostname
880        )
881    }
882
883    fn into_protocol(self) -> Hpkp {
884        Hpkp {
885            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
886            hostname: Annotated::new(self.hostname),
887            port: Annotated::from(self.port),
888            effective_expiration_date: Annotated::from(
889                self.effective_expiration_date.map(|d| d.to_rfc3339()),
890            ),
891            include_subdomains: Annotated::from(self.include_subdomains),
892            noted_hostname: Annotated::from(self.noted_hostname),
893            served_certificate_chain: Annotated::from(
894                self.served_certificate_chain
895                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
896            ),
897            validated_certificate_chain: Annotated::from(
898                self.validated_certificate_chain
899                    .map(|chain| chain.into_iter().map(Annotated::from).collect()),
900            ),
901            known_pins: Annotated::new(self.known_pins.into_iter().map(Annotated::from).collect()),
902            other: self
903                .other
904                .into_iter()
905                .map(|(k, v)| (k, Annotated::from(v)))
906                .collect(),
907        }
908    }
909
910    fn get_tags(&self) -> Tags {
911        let mut tags = vec![Annotated::new(TagEntry(
912            Annotated::new("hostname".to_owned()),
913            Annotated::new(self.hostname.clone()),
914        ))];
915
916        if let Some(port) = self.port {
917            tags.push(Annotated::new(TagEntry(
918                Annotated::new("port".to_owned()),
919                Annotated::new(port.to_string()),
920            )));
921        }
922
923        if let Some(include_subdomains) = self.include_subdomains {
924            tags.push(Annotated::new(TagEntry(
925                Annotated::new("include-subdomains".to_owned()),
926                Annotated::new(include_subdomains.to_string()),
927            )));
928        }
929
930        Tags(PairList::from(tags))
931    }
932
933    fn get_request(&self) -> Request {
934        Request {
935            url: Annotated::from(self.hostname.clone()),
936            ..Request::default()
937        }
938    }
939}
940
941/// Schema as defined in RFC7469, Section 3
942#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
943pub struct Hpkp {
944    /// Indicates the time the UA observed the Pin Validation failure.
945    pub date_time: Annotated<String>,
946    /// Hostname to which the UA made the original request that failed Pin Validation.
947    pub hostname: Annotated<String>,
948    /// The port to which the UA made the original request that failed Pin Validation.
949    pub port: Annotated<u64>,
950    /// Effective Expiration Date for the noted pins.
951    pub effective_expiration_date: Annotated<String>,
952    /// Indicates whether or not the UA has noted the includeSubDomains directive for the Known
953    /// Pinned Host.
954    pub include_subdomains: Annotated<bool>,
955
956    /// Indicates the hostname that the UA noted when it noted the Known Pinned Host.  This field
957    /// allows operators to understand why Pin Validation was performed for, e.g., foo.example.com
958    /// when the noted Known Pinned Host was example.com with includeSubDomains set.
959    pub noted_hostname: Annotated<String>,
960    /// The certificate chain, as served by the Known Pinned Host during TLS session setup.  It
961    /// is provided as an array of strings; each string pem1, ... pemN is the Privacy-Enhanced Mail
962    /// (PEM) representation of each X.509 certificate as described in [RFC7468].
963    ///
964    /// [RFC7468]: https://tools.ietf.org/html/rfc7468
965    pub served_certificate_chain: Annotated<Array<String>>,
966    /// The certificate chain, as constructed by the UA during certificate chain verification.
967    pub validated_certificate_chain: Annotated<Array<String>>,
968
969    /// Pins that the UA has noted for the Known Pinned Host.
970    // TODO: regex this string for 'pin-sha256="ABC123"' syntax
971    #[metastructure(required = true)]
972    pub known_pins: Annotated<Array<String>>,
973
974    #[metastructure(pii = "true", additional_properties)]
975    pub other: Object<Value>,
976}
977
978impl Hpkp {
979    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
980        let raw_hpkp = serde_json::from_slice::<HpkpRaw>(data)?;
981
982        event.logentry = Annotated::new(LogEntry::from(raw_hpkp.get_message()));
983        event.tags = Annotated::new(raw_hpkp.get_tags());
984        event.request = Annotated::new(raw_hpkp.get_request());
985        event.hpkp = Annotated::new(raw_hpkp.into_protocol());
986
987        Ok(())
988    }
989}
990
991/// Defines external, RFC-defined schema we accept, while `ExpectStaple` defines our own schema.
992///
993/// See `ExpectStaple` for meaning of fields.
994#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
995#[serde(rename_all = "kebab-case")]
996struct ExpectStapleReportRaw {
997    expect_staple_report: ExpectStapleRaw,
998}
999
1000#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1001pub enum ExpectStapleResponseStatus {
1002    Missing,
1003    Provided,
1004    ErrorResponse,
1005    BadProducedAt,
1006    NoMatchingResponse,
1007    InvalidDate,
1008    ParseResponseError,
1009    ParseResponseDataError,
1010}
1011
1012relay_common::derive_fromstr_and_display!(ExpectStapleResponseStatus, InvalidSecurityError, {
1013    ExpectStapleResponseStatus::Missing => "MISSING",
1014    ExpectStapleResponseStatus::Provided => "PROVIDED",
1015    ExpectStapleResponseStatus::ErrorResponse => "ERROR_RESPONSE",
1016    ExpectStapleResponseStatus::BadProducedAt => "BAD_PRODUCED_AT",
1017    ExpectStapleResponseStatus::NoMatchingResponse => "NO_MATCHING_RESPONSE",
1018    ExpectStapleResponseStatus::InvalidDate => "INVALID_DATE",
1019    ExpectStapleResponseStatus::ParseResponseError => "PARSE_RESPONSE_ERROR",
1020    ExpectStapleResponseStatus::ParseResponseDataError => "PARSE_RESPONSE_DATA_ERROR",
1021});
1022
1023relay_common::impl_str_serde!(ExpectStapleResponseStatus, "an expect-ct response status");
1024
1025#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1026pub enum ExpectStapleCertStatus {
1027    Good,
1028    Revoked,
1029    Unknown,
1030}
1031
1032relay_common::derive_fromstr_and_display!(ExpectStapleCertStatus, InvalidSecurityError, {
1033    ExpectStapleCertStatus::Good => "GOOD",
1034    ExpectStapleCertStatus::Revoked => "REVOKED",
1035    ExpectStapleCertStatus::Unknown => "UNKNOWN",
1036});
1037
1038relay_common::impl_str_serde!(ExpectStapleCertStatus, "an expect-staple cert status");
1039
1040/// Inner (useful) part of a Expect Stable report as sent by a user agent ( browser)
1041#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
1042#[serde(rename_all = "kebab-case")]
1043struct ExpectStapleRaw {
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    date_time: Option<DateTime<Utc>>,
1046    hostname: String,
1047    #[serde(skip_serializing_if = "Option::is_none")]
1048    port: Option<i64>,
1049    #[serde(skip_serializing_if = "Option::is_none")]
1050    effective_expiration_date: Option<DateTime<Utc>>,
1051    #[serde(skip_serializing_if = "Option::is_none")]
1052    response_status: Option<ExpectStapleResponseStatus>,
1053    #[serde(skip_serializing_if = "Option::is_none")]
1054    ocsp_response: Option<Value>,
1055    #[serde(skip_serializing_if = "Option::is_none")]
1056    cert_status: Option<ExpectStapleCertStatus>,
1057    #[serde(skip_serializing_if = "Option::is_none")]
1058    served_certificate_chain: Option<Vec<String>>,
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    validated_certificate_chain: Option<Vec<String>>,
1061}
1062
1063impl ExpectStapleRaw {
1064    fn get_message(&self) -> String {
1065        format!("Expect-Staple failed for '{}'", self.hostname)
1066    }
1067
1068    fn into_protocol(self) -> ExpectStaple {
1069        ExpectStaple {
1070            date_time: Annotated::from(self.date_time.map(|d| d.to_rfc3339())),
1071            hostname: Annotated::from(self.hostname),
1072            port: Annotated::from(self.port),
1073            effective_expiration_date: Annotated::from(
1074                self.effective_expiration_date.map(|d| d.to_rfc3339()),
1075            ),
1076            response_status: Annotated::from(self.response_status.map(|rs| rs.to_string())),
1077            cert_status: Annotated::from(self.cert_status.map(|cs| cs.to_string())),
1078            served_certificate_chain: Annotated::from(
1079                self.served_certificate_chain
1080                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1081            ),
1082            validated_certificate_chain: Annotated::from(
1083                self.validated_certificate_chain
1084                    .map(|cert_chain| cert_chain.into_iter().map(Annotated::from).collect()),
1085            ),
1086            ocsp_response: Annotated::from(self.ocsp_response),
1087        }
1088    }
1089
1090    fn get_culprit(&self) -> String {
1091        self.hostname.clone()
1092    }
1093
1094    fn get_tags(&self) -> Tags {
1095        let mut tags = vec![Annotated::new(TagEntry(
1096            Annotated::new("hostname".to_owned()),
1097            Annotated::new(self.hostname.clone()),
1098        ))];
1099
1100        if let Some(port) = self.port {
1101            tags.push(Annotated::new(TagEntry(
1102                Annotated::new("port".to_owned()),
1103                Annotated::new(port.to_string()),
1104            )));
1105        }
1106
1107        if let Some(response_status) = self.response_status {
1108            tags.push(Annotated::new(TagEntry(
1109                Annotated::new("response_status".to_owned()),
1110                Annotated::new(response_status.to_string()),
1111            )));
1112        }
1113
1114        if let Some(cert_status) = self.cert_status {
1115            tags.push(Annotated::new(TagEntry(
1116                Annotated::new("cert_status".to_owned()),
1117                Annotated::new(cert_status.to_string()),
1118            )));
1119        }
1120
1121        Tags(PairList::from(tags))
1122    }
1123
1124    fn get_request(&self) -> Request {
1125        Request {
1126            url: Annotated::from(self.hostname.clone()),
1127            ..Request::default()
1128        }
1129    }
1130}
1131
1132/// Represents an Expect Staple security report.
1133///
1134/// See <https://scotthelme.co.uk/ocsp-expect-staple/> for specification.
1135#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
1136pub struct ExpectStaple {
1137    date_time: Annotated<String>,
1138    hostname: Annotated<String>,
1139    port: Annotated<i64>,
1140    effective_expiration_date: Annotated<String>,
1141    response_status: Annotated<String>,
1142    cert_status: Annotated<String>,
1143    served_certificate_chain: Annotated<Array<String>>,
1144    validated_certificate_chain: Annotated<Array<String>>,
1145    ocsp_response: Annotated<Value>,
1146}
1147
1148impl ExpectStaple {
1149    pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
1150        let raw_report = serde_json::from_slice::<ExpectStapleReportRaw>(data)?;
1151        let raw_expect_staple = raw_report.expect_staple_report;
1152
1153        event.logentry = Annotated::new(LogEntry::from(raw_expect_staple.get_message()));
1154        event.culprit = Annotated::new(raw_expect_staple.get_culprit());
1155        event.tags = Annotated::new(raw_expect_staple.get_tags());
1156        event.request = Annotated::new(raw_expect_staple.get_request());
1157        event.expectstaple = Annotated::new(raw_expect_staple.into_protocol());
1158
1159        Ok(())
1160    }
1161}
1162
1163#[derive(Clone, Debug, PartialEq, Eq)]
1164pub enum SecurityReportType {
1165    Csp,
1166    ExpectCt,
1167    ExpectStaple,
1168    Hpkp,
1169    Unsupported,
1170}
1171
1172impl SecurityReportType {
1173    /// Infers the type of a security report from its payload.
1174    ///
1175    /// This looks into the JSON payload and tries to infer the type from keys. If no report
1176    /// matches, an error is returned.
1177    pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
1178        #[derive(Deserialize)]
1179        #[serde(rename_all = "kebab-case")]
1180        struct SecurityReport {
1181            #[serde(rename = "type")]
1182            ty: Option<CspViolationType>,
1183            csp_report: Option<IgnoredAny>,
1184            known_pins: Option<IgnoredAny>,
1185            expect_staple_report: Option<IgnoredAny>,
1186            expect_ct_report: Option<IgnoredAny>,
1187        }
1188
1189        let helper: SecurityReport = serde_json::from_slice(data)?;
1190
1191        Ok(if helper.csp_report.is_some() {
1192            Some(SecurityReportType::Csp)
1193        } else if let Some(CspViolationType::CspViolation) = helper.ty {
1194            Some(SecurityReportType::Csp)
1195        } else if let Some(CspViolationType::Other) = helper.ty {
1196            Some(SecurityReportType::Unsupported)
1197        } else if helper.known_pins.is_some() {
1198            Some(SecurityReportType::Hpkp)
1199        } else if helper.expect_staple_report.is_some() {
1200            Some(SecurityReportType::ExpectStaple)
1201        } else if helper.expect_ct_report.is_some() {
1202            Some(SecurityReportType::ExpectCt)
1203        } else {
1204            None
1205        })
1206    }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211    use super::*;
1212    use relay_protocol::assert_annotated_snapshot;
1213
1214    #[test]
1215    fn test_unsplit_uri() {
1216        assert_eq!(unsplit_uri("", ""), "");
1217        assert_eq!(unsplit_uri("data", ""), "data:");
1218        assert_eq!(unsplit_uri("data", "foo"), "data://foo");
1219        assert_eq!(unsplit_uri("http", ""), "http://");
1220        assert_eq!(unsplit_uri("http", "foo"), "http://foo");
1221    }
1222
1223    #[test]
1224    fn test_normalize_uri() {
1225        // Special handling for self URIs
1226        assert_eq!(normalize_uri(""), "'self'");
1227        assert_eq!(normalize_uri("self"), "'self'");
1228
1229        // Special handling for schema-only URIs
1230        assert_eq!(normalize_uri("data"), "data:");
1231        assert_eq!(normalize_uri("http"), "http://");
1232
1233        // URIs without port
1234        assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
1235        assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
1236        assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
1237        assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
1238
1239        // URIs with port
1240        assert_eq!(
1241            normalize_uri("http://notlocalhost:8000/"),
1242            "notlocalhost:8000"
1243        );
1244        assert_eq!(
1245            normalize_uri("http://notlocalhost:8000/lol.css"),
1246            "notlocalhost:8000"
1247        );
1248
1249        // Invalid URIs
1250        assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
1251    }
1252
1253    #[test]
1254    fn test_csp_basic() {
1255        let json = r#"{
1256            "csp-report": {
1257                "document-uri": "http://example.com",
1258                "violated-directive": "style-src cdn.example.com",
1259                "blocked-uri": "http://example.com/lol.css",
1260                "effective-directive": "style-src",
1261                "status-code": "200"
1262            }
1263        }"#;
1264
1265        let mut event = Event::default();
1266        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1267
1268        assert_annotated_snapshot!(Annotated::new(event), @r###"
1269        {
1270          "culprit": "style-src cdn.example.com",
1271          "logentry": {
1272            "formatted": "Blocked 'style' from 'example.com'"
1273          },
1274          "request": {
1275            "url": "http://example.com"
1276          },
1277          "tags": [
1278            [
1279              "effective-directive",
1280              "style-src"
1281            ],
1282            [
1283              "blocked-uri",
1284              "http://example.com/lol.css"
1285            ],
1286            [
1287              "blocked-host",
1288              "example.com"
1289            ]
1290          ],
1291          "csp": {
1292            "effective_directive": "style-src",
1293            "blocked_uri": "http://example.com/lol.css",
1294            "document_uri": "http://example.com",
1295            "status_code": 200,
1296            "violated_directive": "style-src cdn.example.com"
1297          }
1298        }
1299        "###);
1300    }
1301
1302    #[test]
1303    fn test_csp_coerce_blocked_uri_if_missing() {
1304        let json = r#"{
1305            "csp-report": {
1306                "document-uri": "http://example.com",
1307                "effective-directive": "script-src"
1308            }
1309        }"#;
1310
1311        let mut event = Event::default();
1312        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1313
1314        assert_annotated_snapshot!(Annotated::new(event), @r#"
1315        {
1316          "culprit": "",
1317          "logentry": {
1318            "formatted": "Blocked unsafe (eval() or inline) 'script'"
1319          },
1320          "request": {
1321            "url": "http://example.com"
1322          },
1323          "tags": [
1324            [
1325              "effective-directive",
1326              "script-src"
1327            ],
1328            [
1329              "blocked-uri",
1330              "self"
1331            ]
1332          ],
1333          "csp": {
1334            "effective_directive": "script-src",
1335            "blocked_uri": "self",
1336            "document_uri": "http://example.com",
1337            "violated_directive": ""
1338          }
1339        }
1340        "#);
1341    }
1342
1343    #[test]
1344    fn test_csp_msdn() {
1345        let json = r#"{
1346            "csp-report": {
1347                "document-uri": "https://example.com/foo/bar",
1348                "referrer": "https://www.google.com/",
1349                "violated-directive": "default-src self",
1350                "original-policy": "default-src self; report-uri /csp-hotline.php",
1351                "blocked-uri": "http://evilhackerscripts.com"
1352            }
1353        }"#;
1354
1355        let mut event = Event::default();
1356        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1357
1358        assert_annotated_snapshot!(Annotated::new(event), @r###"
1359        {
1360          "culprit": "default-src self",
1361          "logentry": {
1362            "formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
1363          },
1364          "request": {
1365            "url": "https://example.com/foo/bar",
1366            "headers": [
1367              [
1368                "Referer",
1369                "https://www.google.com/"
1370              ]
1371            ]
1372          },
1373          "tags": [
1374            [
1375              "effective-directive",
1376              "default-src"
1377            ],
1378            [
1379              "blocked-uri",
1380              "http://evilhackerscripts.com"
1381            ],
1382            [
1383              "blocked-host",
1384              "evilhackerscripts.com"
1385            ]
1386          ],
1387          "csp": {
1388            "effective_directive": "default-src",
1389            "blocked_uri": "http://evilhackerscripts.com",
1390            "document_uri": "https://example.com/foo/bar",
1391            "original_policy": "default-src self; report-uri /csp-hotline.php",
1392            "referrer": "https://www.google.com/",
1393            "violated_directive": "default-src self"
1394          }
1395        }
1396        "###);
1397    }
1398
1399    #[test]
1400    fn test_csp_real() {
1401        let json = r#"{
1402            "csp-report": {
1403                "document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
1404                "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1405                "violated-directive": "script-src",
1406                "effective-directive": "script-src",
1407                "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",
1408                "disposition": "enforce",
1409                "blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
1410                "line-number": 24,
1411                "column-number": 66270,
1412                "source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1413                "status-code": 0,
1414                "script-sample": ""
1415            }
1416        }"#;
1417
1418        let mut event = Event::default();
1419        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1420
1421        assert_annotated_snapshot!(Annotated::new(event), @r###"
1422        {
1423          "culprit": "script-src",
1424          "logentry": {
1425            "formatted": "Blocked 'script' from 'baddomain.com'"
1426          },
1427          "request": {
1428            "url": "https://sentry.io/sentry/csp/issues/88513416/",
1429            "headers": [
1430              [
1431                "Referer",
1432                "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
1433              ]
1434            ]
1435          },
1436          "tags": [
1437            [
1438              "effective-directive",
1439              "script-src"
1440            ],
1441            [
1442              "blocked-uri",
1443              "http://baddomain.com/test.js?_=1515535030116"
1444            ],
1445            [
1446              "blocked-host",
1447              "baddomain.com"
1448            ]
1449          ],
1450          "csp": {
1451            "effective_directive": "script-src",
1452            "blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
1453            "document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
1454            "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",
1455            "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
1456            "status_code": 0,
1457            "violated_directive": "script-src",
1458            "source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
1459            "line_number": 24,
1460            "column_number": 66270,
1461            "script_sample": "",
1462            "disposition": "enforce"
1463          }
1464        }
1465        "###);
1466    }
1467
1468    #[test]
1469    fn test_csp_culprit_0() {
1470        let json = r#"{
1471            "csp-report": {
1472                "document-uri": "http://example.com/foo",
1473                "violated-directive": "style-src http://cdn.example.com",
1474                "effective-directive": "style-src"
1475            }
1476        }"#;
1477
1478        let mut event = Event::default();
1479        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1480        insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://cdn.example.com""#);
1481    }
1482
1483    #[test]
1484    fn test_csp_culprit_1() {
1485        let json = r#"{
1486            "csp-report": {
1487                "document-uri": "http://example.com/foo",
1488                "violated-directive": "style-src cdn.example.com",
1489                "effective-directive": "style-src"
1490            }
1491        }"#;
1492
1493        let mut event = Event::default();
1494        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1495        insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
1496    }
1497
1498    #[test]
1499    fn test_csp_culprit_2() {
1500        let json = r#"{
1501            "csp-report": {
1502                "document-uri": "https://example.com/foo",
1503                "violated-directive": "style-src cdn.example.com",
1504                "effective-directive": "style-src"
1505            }
1506        }"#;
1507
1508        let mut event = Event::default();
1509        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1510        insta::assert_debug_snapshot!(event.culprit, @r#""style-src cdn.example.com""#);
1511    }
1512
1513    #[test]
1514    fn test_csp_culprit_3() {
1515        let json = r#"{
1516            "csp-report": {
1517                "document-uri": "http://example.com/foo",
1518                "violated-directive": "style-src https://cdn.example.com",
1519                "effective-directive": "style-src"
1520            }
1521        }"#;
1522
1523        let mut event = Event::default();
1524        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1525        insta::assert_debug_snapshot!(event.culprit, @r#""style-src https://cdn.example.com""#);
1526    }
1527
1528    #[test]
1529    fn test_csp_culprit_4() {
1530        let json = r#"{
1531            "csp-report": {
1532                "document-uri": "http://example.com/foo",
1533                "violated-directive": "style-src http://example.com",
1534                "effective-directive": "style-src"
1535            }
1536        }"#;
1537
1538        let mut event = Event::default();
1539        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1540        insta::assert_debug_snapshot!(event.culprit, @r#""style-src 'self'""#);
1541    }
1542
1543    #[test]
1544    fn test_csp_culprit_5() {
1545        let json = r#"{
1546            "csp-report": {
1547                "document-uri": "http://example.com/foo",
1548                "violated-directive": "style-src http://example2.com example.com",
1549                "effective-directive": "style-src"
1550            }
1551        }"#;
1552
1553        let mut event = Event::default();
1554        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1555        insta::assert_debug_snapshot!(event.culprit, @r#""style-src http://example2.com 'self'""#);
1556    }
1557
1558    #[test]
1559    fn test_csp_culprit_uri_without_scheme() {
1560        // Not sure if this is a real-world example, but let's cover it anyway
1561        let json = r#"{
1562            "csp-report": {
1563                "document-uri": "example.com",
1564                "violated-directive": "style-src example2.com"
1565            }
1566        }"#;
1567
1568        let mut event = Event::default();
1569        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1570        insta::assert_debug_snapshot!(event.culprit, @r#""style-src example2.com""#);
1571    }
1572
1573    #[test]
1574    fn test_csp_tags_stripe() {
1575        // This is a regression test for potential PII in stripe URLs. PII stripping used to skip
1576        // report interfaces, which is why there is special handling.
1577
1578        let json = r#"{
1579            "csp-report": {
1580                "document-uri": "https://example.com",
1581                "blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
1582                "effective-directive": "script-src"
1583            }
1584        }"#;
1585
1586        let mut event = Event::default();
1587        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1588        insta::assert_debug_snapshot!(event.tags, @r###"
1589        Tags(
1590            PairList(
1591                [
1592                    TagEntry(
1593                        "effective-directive",
1594                        "script-src",
1595                    ),
1596                    TagEntry(
1597                        "blocked-uri",
1598                        "https://api.stripe.com/v1/tokens",
1599                    ),
1600                    TagEntry(
1601                        "blocked-host",
1602                        "api.stripe.com",
1603                    ),
1604                ],
1605            ),
1606        )
1607        "###);
1608    }
1609
1610    #[test]
1611    fn test_csp_get_message_0() {
1612        let json = r#"{
1613            "csp-report": {
1614                "document-uri": "http://example.com/foo",
1615                "effective-directive": "img-src",
1616                "blocked-uri": "http://google.com/foo"
1617            }
1618        }"#;
1619
1620        let mut event = Event::default();
1621        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1622        let message = &event.logentry.value().unwrap().formatted;
1623        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked 'image' from 'google.com'""#);
1624    }
1625
1626    #[test]
1627    fn test_csp_get_message_1() {
1628        let json = r#"{
1629            "csp-report": {
1630                "document-uri": "http://example.com/foo",
1631                "effective-directive": "style-src",
1632                "blocked-uri": ""
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 inline 'style'""#);
1640    }
1641
1642    #[test]
1643    fn test_csp_get_message_2() {
1644        let json = r#"{
1645            "csp-report": {
1646                "document-uri": "http://example.com/foo",
1647                "effective-directive": "script-src",
1648                "blocked-uri": "",
1649                "violated-directive": "script-src 'unsafe-inline'"
1650            }
1651        }"#;
1652
1653        let mut event = Event::default();
1654        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1655        let message = &event.logentry.value().unwrap().formatted;
1656        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe inline 'script'""#);
1657    }
1658
1659    #[test]
1660    fn test_csp_get_message_3() {
1661        let json = r#"{
1662            "csp-report": {
1663                "document-uri": "http://example.com/foo",
1664                "effective-directive": "script-src",
1665                "blocked-uri": "",
1666                "violated-directive": "script-src 'unsafe-eval'"
1667            }
1668        }"#;
1669
1670        let mut event = Event::default();
1671        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1672        let message = &event.logentry.value().unwrap().formatted;
1673        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe eval() 'script'""#);
1674    }
1675
1676    #[test]
1677    fn test_csp_get_message_4() {
1678        let json = r#"{
1679            "csp-report": {
1680                "document-uri": "http://example.com/foo",
1681                "effective-directive": "script-src",
1682                "blocked-uri": "",
1683                "violated-directive": "script-src example.com"
1684            }
1685        }"#;
1686
1687        let mut event = Event::default();
1688        Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1689        let message = &event.logentry.value().unwrap().formatted;
1690        insta::assert_debug_snapshot!(message.as_str().unwrap(), @r#""Blocked unsafe (eval() or inline) 'script'""#);
1691    }
1692
1693    #[test]
1694    fn test_csp_get_message_5() {
1695        let json = r#"{
1696            "csp-report": {
1697                "document-uri": "http://example.com/foo",
1698                "effective-directive": "script-src",
1699                "blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
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 'script' from 'data:'""#);
1707    }
1708
1709    #[test]
1710    fn test_csp_get_message_6() {
1711        let json = r#"{
1712            "csp-report": {
1713                "document-uri": "http://example.com/foo",
1714                "effective-directive": "script-src",
1715                "blocked-uri": "data"
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_7() {
1727        let json = r#"{
1728            "csp-report": {
1729                "document-uri": "http://example.com/foo",
1730                "effective-directive": "style-src-elem",
1731                "blocked-uri": "http://fonts.google.com/foo"
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 'style' from 'fonts.google.com'""#);
1739    }
1740
1741    #[test]
1742    fn test_csp_get_message_8() {
1743        let json = r#"{
1744            "csp-report": {
1745                "document-uri": "http://example.com/foo",
1746                "effective-directive": "script-src-elem",
1747                "blocked-uri": "http://cdn.ajaxapis.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 'script' from 'cdn.ajaxapis.com'""#);
1755    }
1756
1757    #[test]
1758    fn test_csp_get_message_9() {
1759        let json = r#"{
1760            "csp-report": {
1761                "document-uri": "http://notlocalhost:8000/",
1762                "effective-directive": "style-src",
1763                "blocked-uri": "http://notlocalhost:8000/lol.css"
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 'style' from 'notlocalhost:8000'""#);
1771    }
1772
1773    #[test]
1774    fn test_expectct_basic() {
1775        let json = r#"{
1776            "expect-ct-report": {
1777                "date-time": "2014-04-06T13:00:50Z",
1778                "hostname": "www.example.com",
1779                "port": 443,
1780                "scheme": "https",
1781                "effective-expiration-date": "2014-05-01T12:40:50Z",
1782                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1783                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1784                "scts": [
1785                    {
1786                        "version": 1,
1787                        "status": "invalid",
1788                        "source": "embedded",
1789                        "serialized_sct": "ABCD=="
1790                    }
1791                ],
1792                "failure-mode": "enforce",
1793                "test-report": false
1794            }
1795        }"#;
1796
1797        let mut event = Event::default();
1798        ExpectCt::apply_to_event(json.as_bytes(), &mut event).unwrap();
1799        assert_annotated_snapshot!(Annotated::new(event), @r#"
1800        {
1801          "culprit": "www.example.com",
1802          "logentry": {
1803            "formatted": "Expect-CT failed for 'www.example.com'"
1804          },
1805          "request": {
1806            "url": "www.example.com"
1807          },
1808          "tags": [
1809            [
1810              "hostname",
1811              "www.example.com"
1812            ],
1813            [
1814              "port",
1815              "443"
1816            ]
1817          ],
1818          "expectct": {
1819            "date_time": "2014-04-06T13:00:50+00:00",
1820            "hostname": "www.example.com",
1821            "port": 443,
1822            "scheme": "https",
1823            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1824            "served_certificate_chain": [
1825              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1826            ],
1827            "validated_certificate_chain": [
1828              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1829            ],
1830            "scts": [
1831              {
1832                "version": 1,
1833                "status": "invalid",
1834                "source": "embedded",
1835                "serialized_sct": "ABCD=="
1836              }
1837            ],
1838            "failure_mode": "enforce",
1839            "test_report": false
1840          }
1841        }
1842        "#);
1843    }
1844
1845    #[test]
1846    fn test_expectct_invalid() {
1847        let json = r#"{
1848            "hostname": "www.example.com",
1849            "date_time": "Not an RFC3339 datetime"
1850        }"#;
1851
1852        let mut event = Event::default();
1853        ExpectCt::apply_to_event(json.as_bytes(), &mut event)
1854            .expect_err("date_time should fail to parse");
1855    }
1856
1857    #[test]
1858    fn test_expectstaple_basic() {
1859        let json = r#"{
1860            "expect-staple-report": {
1861                "date-time": "2014-04-06T13:00:50Z",
1862                "hostname": "www.example.com",
1863                "port": 443,
1864                "response-status": "ERROR_RESPONSE",
1865                "cert-status": "REVOKED",
1866                "effective-expiration-date": "2014-05-01T12:40:50Z",
1867                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1868                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
1869            }
1870        }"#;
1871
1872        let mut event = Event::default();
1873        ExpectStaple::apply_to_event(json.as_bytes(), &mut event).unwrap();
1874        assert_annotated_snapshot!(Annotated::new(event), @r#"
1875        {
1876          "culprit": "www.example.com",
1877          "logentry": {
1878            "formatted": "Expect-Staple failed for 'www.example.com'"
1879          },
1880          "request": {
1881            "url": "www.example.com"
1882          },
1883          "tags": [
1884            [
1885              "hostname",
1886              "www.example.com"
1887            ],
1888            [
1889              "port",
1890              "443"
1891            ],
1892            [
1893              "response_status",
1894              "ERROR_RESPONSE"
1895            ],
1896            [
1897              "cert_status",
1898              "REVOKED"
1899            ]
1900          ],
1901          "expectstaple": {
1902            "date_time": "2014-04-06T13:00:50+00:00",
1903            "hostname": "www.example.com",
1904            "port": 443,
1905            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1906            "response_status": "ERROR_RESPONSE",
1907            "cert_status": "REVOKED",
1908            "served_certificate_chain": [
1909              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1910            ],
1911            "validated_certificate_chain": [
1912              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1913            ]
1914          }
1915        }
1916        "#);
1917    }
1918
1919    #[test]
1920    fn test_hpkp_basic() {
1921        let json = r#"{
1922            "date-time": "2014-04-06T13:00:50Z",
1923            "hostname": "example.com",
1924            "port": 443,
1925            "effective-expiration-date": "2014-05-01T12:40:50Z",
1926            "include-subdomains": false,
1927            "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1928            "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1929            "known-pins": ["pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""]
1930        }"#;
1931
1932        let mut event = Event::default();
1933        Hpkp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1934        assert_annotated_snapshot!(Annotated::new(event), @r#"
1935        {
1936          "logentry": {
1937            "formatted": "Public key pinning validation failed for 'example.com'"
1938          },
1939          "request": {
1940            "url": "example.com"
1941          },
1942          "tags": [
1943            [
1944              "hostname",
1945              "example.com"
1946            ],
1947            [
1948              "port",
1949              "443"
1950            ],
1951            [
1952              "include-subdomains",
1953              "false"
1954            ]
1955          ],
1956          "hpkp": {
1957            "date_time": "2014-04-06T13:00:50+00:00",
1958            "hostname": "example.com",
1959            "port": 443,
1960            "effective_expiration_date": "2014-05-01T12:40:50+00:00",
1961            "include_subdomains": false,
1962            "served_certificate_chain": [
1963              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1964            ],
1965            "validated_certificate_chain": [
1966              "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1967            ],
1968            "known_pins": [
1969              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
1970            ]
1971          }
1972        }
1973        "#);
1974    }
1975
1976    #[test]
1977    fn test_security_report_type_deserializer_recognizes_csp_reports() {
1978        let csp_report_text = r#"{
1979            "csp-report": {
1980                "document-uri": "https://example.com/foo/bar",
1981                "referrer": "https://www.google.com/",
1982                "violated-directive": "default-src self",
1983                "original-policy": "default-src self; report-uri /csp-hotline.php",
1984                "blocked-uri": "http://evilhackerscripts.com"
1985            }
1986        }"#;
1987
1988        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1989        assert_eq!(report_type, Some(SecurityReportType::Csp));
1990    }
1991
1992    #[test]
1993    fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
1994        let csp_report_text = r#"{
1995          "age":0,
1996          "body":{
1997            "blockedURL":"https://example.com/tst/media/7_del.png",
1998            "disposition":"enforce",
1999            "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2000            "effectiveDirective":"img-src",
2001            "lineNumber":9,
2002            "originalPolicy":"default-src 'none'; report-to endpoint-csp;",
2003            "referrer":"https://example.com/test229/",
2004            "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2005            "statusCode":0
2006            },
2007          "type":"csp-violation",
2008          "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
2009          "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"
2010        }"#;
2011
2012        let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
2013        assert_eq!(report_type, Some(SecurityReportType::Csp));
2014    }
2015
2016    #[test]
2017    fn test_security_report_type_deserializer_recognizes_expect_ct_reports() {
2018        let expect_ct_report_text = r#"{
2019            "expect-ct-report": {
2020                "date-time": "2014-04-06T13:00:50Z",
2021                "hostname": "www.example.com",
2022                "port": 443,
2023                "effective-expiration-date": "2014-05-01T12:40:50Z",
2024                "served-certificate-chain": [
2025                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2026                ],
2027                "validated-certificate-chain": [
2028                    "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
2029                ],
2030                "scts": [
2031                    {
2032                        "version": 1,
2033                        "status": "invalid",
2034                        "source": "embedded",
2035                        "serialized_sct": "ABCD=="
2036                    }
2037                ]
2038            }
2039        }"#;
2040
2041        let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
2042        assert_eq!(report_type, Some(SecurityReportType::ExpectCt));
2043    }
2044
2045    #[test]
2046    fn test_security_report_type_deserializer_recognizes_expect_staple_reports() {
2047        let expect_staple_report_text = r#"{
2048             "expect-staple-report": {
2049                "date-time": "2014-04-06T13:00:50Z",
2050                "hostname": "www.example.com",
2051                "port": 443,
2052                "response-status": "ERROR_RESPONSE",
2053                "cert-status": "REVOKED",
2054                "effective-expiration-date": "2014-05-01T12:40:50Z",
2055                "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
2056                "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
2057            }
2058        }"#;
2059        let report_type =
2060            SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
2061        assert_eq!(report_type, Some(SecurityReportType::ExpectStaple));
2062    }
2063
2064    #[test]
2065    fn test_security_report_type_deserializer_recognizes_hpkp_reports() {
2066        let hpkp_report_text = r#"{
2067            "date-time": "2014-04-06T13:00:50Z",
2068            "hostname": "www.example.com",
2069            "port": 443,
2070            "effective-expiration-date": "2014-05-01T12:40:50Z",
2071            "include-subdomains": false,
2072            "served-certificate-chain": [
2073              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2074            ],
2075            "validated-certificate-chain": [
2076              "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
2077            ],
2078            "known-pins": [
2079              "pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
2080              "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
2081            ]
2082          }"#;
2083
2084        let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
2085        assert_eq!(report_type, Some(SecurityReportType::Hpkp));
2086    }
2087
2088    #[test]
2089    fn test_effective_directive_from_violated_directive_single() {
2090        // Example from Firefox:
2091        let csp_raw: CspRaw =
2092            serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
2093        assert!(matches!(
2094            csp_raw.effective_directive(),
2095            Ok(CspDirective::DefaultSrc)
2096        ));
2097    }
2098
2099    #[test]
2100    fn test_extract_effective_directive_from_long_form() {
2101        // First try from 'effective-directive' field
2102        let json = r#"{
2103            "csp-report": {
2104                "document-uri": "http://example.com/foo",
2105                "effective-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
2106                "blocked-uri": "data"
2107            }
2108        }"#;
2109
2110        let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
2111        let raw_csp = raw_report.csp_report;
2112
2113        let effective_directive = raw_csp.effective_directive().unwrap();
2114
2115        assert_eq!(effective_directive, CspDirective::ScriptSrc);
2116
2117        // Then from 'violated-directive' field
2118        let json = r#"{
2119            "csp-report": {
2120                "document-uri": "http://example.com/foo",
2121                "violated-directive": "script-src 'report-sample' 'strict-dynamic' 'unsafe-eval' 'nonce-random" ,
2122                "blocked-uri": "data"
2123            }
2124        }"#;
2125
2126        let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
2127        let raw_csp = raw_report.csp_report;
2128
2129        let effective_directive = raw_csp.effective_directive().unwrap();
2130
2131        assert_eq!(effective_directive, CspDirective::ScriptSrc);
2132    }
2133
2134    #[test]
2135    fn test_csp_report_fenced_frame_src() {
2136        let json = r#"
2137            {
2138              "csp-report": {
2139                "document-uri": "https://example.com",
2140                "referrer": "https://www.google.com/",
2141                "violated-directive": "fenced-frame-src",
2142                "effective-directive": "fenced-frame-src",
2143                "original-policy": "default-src 'self' 'unsafe-eval'",
2144                "disposition": "enforce",
2145                "blocked-uri": "",
2146                "status-code": 200,
2147                "script-sample": ""
2148              }
2149            }
2150        "#;
2151        let raw_report = serde_json::from_slice::<CspReportRaw>(json.as_bytes()).unwrap();
2152        let raw_csp = raw_report.csp_report;
2153
2154        assert!(raw_csp.effective_directive().is_ok());
2155    }
2156}