Skip to main content

relay_event_schema/protocol/
security_report.rs

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