1use 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 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
140fn 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 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#[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 if let Some(directive) = &self.effective_directive {
292 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 let mut uri = self.blocked_uri.clone();
397
398 if uri.starts_with("https://api.stripe.com/")
399 && let Some(index) = uri.find(&['#', '?'][..])
400 {
401 uri.truncate(index);
402 }
403
404 uri
405 }
406
407 fn normalize_value<'a>(&self, value: &'a str, document_uri: &str) -> Cow<'a, str> {
408 if let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
412 return Cow::Borrowed(value);
413 }
414
415 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 return Cow::Borrowed(value);
432 }
433
434 if value == document_uri {
437 return Cow::Borrowed("'self'");
438 }
439
440 let original_uri = self.document_uri.as_deref().unwrap_or_default();
443 match original_uri.split_once(':').map(|x| x.0) {
444 None | Some("http" | "https") => Cow::Borrowed(value),
445 Some(scheme) => Cow::Owned(unsplit_uri(scheme, value)),
446 }
447 }
448
449 fn get_culprit(&self) -> String {
450 if self.violated_directive.is_empty() {
451 return String::new();
452 }
453
454 let mut bits = self.violated_directive.split_ascii_whitespace();
455 let mut culprit = bits.next().unwrap_or_default().to_owned();
456
457 let document_uri = self.document_uri.as_deref().unwrap_or("");
458 let normalized_uri = normalize_uri(document_uri);
459
460 for bit in bits {
461 write!(culprit, " {}", self.normalize_value(bit, &normalized_uri)).ok();
462 }
463
464 culprit
465 }
466
467 fn get_tags(&self, effective_directive: CspDirective) -> Tags {
468 let mut tags = vec![
469 Annotated::new(TagEntry(
470 Annotated::new("effective-directive".to_owned()),
471 Annotated::new(effective_directive.to_string()),
472 )),
473 Annotated::new(TagEntry(
474 Annotated::new("blocked-uri".to_owned()),
475 Annotated::new(self.sanitized_blocked_uri()),
476 )),
477 ];
478
479 if let Ok(url) = Url::parse(&self.blocked_uri)
480 && let ("http" | "https", Some(host)) = (url.scheme(), url.host_str())
481 {
482 tags.push(Annotated::new(TagEntry(
483 Annotated::new("blocked-host".to_owned()),
484 Annotated::new(host.to_owned()),
485 )));
486 }
487
488 Tags(PairList::from(tags))
489 }
490
491 fn get_request(&self) -> Request {
492 let headers = match self.referrer {
493 Some(ref referrer) if !referrer.is_empty() => {
494 Annotated::new(Headers(PairList(vec![Annotated::new((
495 Annotated::new(HeaderName::new("Referer")),
496 Annotated::new(HeaderValue::new(referrer.clone())),
497 ))])))
498 }
499 Some(_) | None => Annotated::empty(),
500 };
501
502 Request {
503 url: Annotated::from(self.document_uri.clone()),
504 headers,
505 ..Request::default()
506 }
507 }
508}
509
510#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
511#[serde(untagged)]
512enum CspVariant {
513 Csp {
514 #[serde(rename = "csp-report")]
515 csp_report: CspRaw,
516 },
517 CspViolation { body: CspRaw },
524}
525
526#[derive(Clone, Debug, PartialEq, Deserialize)]
528#[serde(rename_all = "kebab-case")]
529enum CspViolationType {
530 CspViolation,
531 #[serde(other)]
532 Other,
533}
534
535#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
547pub struct Csp {
548 #[metastructure(pii = "true")]
550 pub effective_directive: Annotated<String>,
551 #[metastructure(pii = "true")]
553 pub blocked_uri: Annotated<String>,
554 #[metastructure(pii = "true")]
556 pub document_uri: Annotated<String>,
557 pub original_policy: Annotated<String>,
559 #[metastructure(pii = "true")]
561 pub referrer: Annotated<String>,
562 pub status_code: Annotated<u64>,
564 pub violated_directive: Annotated<String>,
566 #[metastructure(pii = "maybe")]
568 pub source_file: Annotated<String>,
569 pub line_number: Annotated<u64>,
571 pub column_number: Annotated<u64>,
573 pub script_sample: Annotated<String>,
576 pub disposition: Annotated<String>,
578 #[metastructure(pii = "true", additional_properties)]
580 pub other: Object<Value>,
581}
582
583impl Csp {
584 pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> {
585 let variant = serde_json::from_slice::<CspVariant>(data)?;
586 match variant {
587 CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?,
588 CspVariant::CspViolation { body } => Csp::extract_report(event, body)?,
589 }
590
591 Ok(())
592 }
593
594 fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> {
595 let effective_directive = raw_csp
596 .effective_directive()
597 .map_err(serde::de::Error::custom)?;
598
599 event.logentry = Annotated::new(LogEntry::from(raw_csp.get_message(effective_directive)));
600 event.culprit = Annotated::new(raw_csp.get_culprit());
601 event.tags = Annotated::new(raw_csp.get_tags(effective_directive));
602 event.request = Annotated::new(raw_csp.get_request());
603 event.csp = Annotated::new(raw_csp.into_protocol(effective_directive));
604
605 Ok(())
606 }
607}
608
609#[derive(Clone, Debug, PartialEq, Eq)]
610pub enum SecurityReportType {
611 Csp,
612 Unsupported,
613}
614
615impl SecurityReportType {
616 pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
621 #[derive(Deserialize)]
622 #[serde(rename_all = "kebab-case")]
623 struct SecurityReport {
624 #[serde(rename = "type")]
625 ty: Option<CspViolationType>,
626 csp_report: Option<IgnoredAny>,
627 }
628
629 let helper: SecurityReport = serde_json::from_slice(data)?;
630
631 Ok(if helper.csp_report.is_some() {
632 Some(SecurityReportType::Csp)
633 } else if let Some(CspViolationType::CspViolation) = helper.ty {
634 Some(SecurityReportType::Csp)
635 } else if let Some(CspViolationType::Other) = helper.ty {
636 Some(SecurityReportType::Unsupported)
637 } else {
638 None
639 })
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use relay_protocol::assert_annotated_snapshot;
647
648 #[test]
649 fn test_unsplit_uri() {
650 assert_eq!(unsplit_uri("", ""), "");
651 assert_eq!(unsplit_uri("data", ""), "data:");
652 assert_eq!(unsplit_uri("data", "foo"), "data://foo");
653 assert_eq!(unsplit_uri("http", ""), "http://");
654 assert_eq!(unsplit_uri("http", "foo"), "http://foo");
655 }
656
657 #[test]
658 fn test_normalize_uri() {
659 assert_eq!(normalize_uri(""), "'self'");
661 assert_eq!(normalize_uri("self"), "'self'");
662
663 assert_eq!(normalize_uri("data"), "data:");
665 assert_eq!(normalize_uri("http"), "http://");
666
667 assert_eq!(normalize_uri("http://notlocalhost/"), "notlocalhost");
669 assert_eq!(normalize_uri("https://notlocalhost/"), "notlocalhost");
670 assert_eq!(normalize_uri("data://notlocalhost/"), "data://notlocalhost");
671 assert_eq!(normalize_uri("http://notlocalhost/lol.css"), "notlocalhost");
672
673 assert_eq!(
675 normalize_uri("http://notlocalhost:8000/"),
676 "notlocalhost:8000"
677 );
678 assert_eq!(
679 normalize_uri("http://notlocalhost:8000/lol.css"),
680 "notlocalhost:8000"
681 );
682
683 assert_eq!(normalize_uri("xyz://notlocalhost/"), "xyz://notlocalhost");
685 }
686
687 #[test]
688 fn test_csp_basic() {
689 let json = r#"{
690 "csp-report": {
691 "document-uri": "http://example.com",
692 "violated-directive": "style-src cdn.example.com",
693 "blocked-uri": "http://example.com/lol.css",
694 "effective-directive": "style-src",
695 "status-code": "200"
696 }
697 }"#;
698
699 let mut event = Event::default();
700 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
701
702 assert_annotated_snapshot!(Annotated::new(event), @r###"
703 {
704 "culprit": "style-src cdn.example.com",
705 "logentry": {
706 "formatted": "Blocked 'style' from 'example.com'"
707 },
708 "request": {
709 "url": "http://example.com"
710 },
711 "tags": [
712 [
713 "effective-directive",
714 "style-src"
715 ],
716 [
717 "blocked-uri",
718 "http://example.com/lol.css"
719 ],
720 [
721 "blocked-host",
722 "example.com"
723 ]
724 ],
725 "csp": {
726 "effective_directive": "style-src",
727 "blocked_uri": "http://example.com/lol.css",
728 "document_uri": "http://example.com",
729 "status_code": 200,
730 "violated_directive": "style-src cdn.example.com"
731 }
732 }
733 "###);
734 }
735
736 #[test]
737 fn test_csp_coerce_blocked_uri_if_missing() {
738 let json = r#"{
739 "csp-report": {
740 "document-uri": "http://example.com",
741 "effective-directive": "script-src"
742 }
743 }"#;
744
745 let mut event = Event::default();
746 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
747
748 assert_annotated_snapshot!(Annotated::new(event), @r###"
749 {
750 "culprit": "",
751 "logentry": {
752 "formatted": "Blocked unsafe (eval() or inline) 'script'"
753 },
754 "request": {
755 "url": "http://example.com"
756 },
757 "tags": [
758 [
759 "effective-directive",
760 "script-src"
761 ],
762 [
763 "blocked-uri",
764 "self"
765 ]
766 ],
767 "csp": {
768 "effective_directive": "script-src",
769 "blocked_uri": "self",
770 "document_uri": "http://example.com",
771 "violated_directive": ""
772 }
773 }
774 "###);
775 }
776
777 #[test]
778 fn test_csp_msdn() {
779 let json = r#"{
780 "csp-report": {
781 "document-uri": "https://example.com/foo/bar",
782 "referrer": "https://www.google.com/",
783 "violated-directive": "default-src self",
784 "original-policy": "default-src self; report-uri /csp-hotline.php",
785 "blocked-uri": "http://evilhackerscripts.com"
786 }
787 }"#;
788
789 let mut event = Event::default();
790 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
791
792 assert_annotated_snapshot!(Annotated::new(event), @r###"
793 {
794 "culprit": "default-src self",
795 "logentry": {
796 "formatted": "Blocked 'default-src' from 'evilhackerscripts.com'"
797 },
798 "request": {
799 "url": "https://example.com/foo/bar",
800 "headers": [
801 [
802 "Referer",
803 "https://www.google.com/"
804 ]
805 ]
806 },
807 "tags": [
808 [
809 "effective-directive",
810 "default-src"
811 ],
812 [
813 "blocked-uri",
814 "http://evilhackerscripts.com"
815 ],
816 [
817 "blocked-host",
818 "evilhackerscripts.com"
819 ]
820 ],
821 "csp": {
822 "effective_directive": "default-src",
823 "blocked_uri": "http://evilhackerscripts.com",
824 "document_uri": "https://example.com/foo/bar",
825 "original_policy": "default-src self; report-uri /csp-hotline.php",
826 "referrer": "https://www.google.com/",
827 "violated_directive": "default-src self"
828 }
829 }
830 "###);
831 }
832
833 #[test]
834 fn test_csp_real() {
835 let json = r#"{
836 "csp-report": {
837 "document-uri": "https://sentry.io/sentry/csp/issues/88513416/",
838 "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
839 "violated-directive": "script-src",
840 "effective-directive": "script-src",
841 "original-policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
842 "disposition": "enforce",
843 "blocked-uri": "http://baddomain.com/test.js?_=1515535030116",
844 "line-number": 24,
845 "column-number": 66270,
846 "source-file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
847 "status-code": 0,
848 "script-sample": ""
849 }
850 }"#;
851
852 let mut event = Event::default();
853 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
854
855 assert_annotated_snapshot!(Annotated::new(event), @r###"
856 {
857 "culprit": "script-src",
858 "logentry": {
859 "formatted": "Blocked 'script' from 'baddomain.com'"
860 },
861 "request": {
862 "url": "https://sentry.io/sentry/csp/issues/88513416/",
863 "headers": [
864 [
865 "Referer",
866 "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/"
867 ]
868 ]
869 },
870 "tags": [
871 [
872 "effective-directive",
873 "script-src"
874 ],
875 [
876 "blocked-uri",
877 "http://baddomain.com/test.js?_=1515535030116"
878 ],
879 [
880 "blocked-host",
881 "baddomain.com"
882 ]
883 ],
884 "csp": {
885 "effective_directive": "script-src",
886 "blocked_uri": "http://baddomain.com/test.js?_=1515535030116",
887 "document_uri": "https://sentry.io/sentry/csp/issues/88513416/",
888 "original_policy": "default-src *; script-src 'make_csp_snapshot' 'unsafe-eval' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net cdn.ravenjs.com assets.zendesk.com ajax.googleapis.com ssl.google-analytics.com www.googleadservices.com analytics.twitter.com platform.twitter.com *.pingdom.net js.stripe.com api.stripe.com statuspage-production.s3.amazonaws.com s3.amazonaws.com *.google.com www.gstatic.com aui-cdn.atlassian.com *.atlassian.net *.jira.com *.zopim.com; font-src * data:; connect-src * wss://*.zopim.com; style-src 'make_csp_snapshot' 'unsafe-inline' e90d271df3e973c7.global.ssl.fastly.net s3.amazonaws.com aui-cdn.atlassian.com fonts.googleapis.com; img-src * data: blob:; report-uri https://sentry.io/api/54785/csp-report/?sentry_key=f724a8a027db45f5b21507e7142ff78e&sentry_release=39662eb9734f68e56b7f202260bb706be2f4cee7",
889 "referrer": "https://sentry.io/sentry/sentry/releases/7329107476ff14cfa19cf013acd8ce47781bb93a/",
890 "status_code": 0,
891 "violated_directive": "script-src",
892 "source_file": "https://e90d271df3e973c7.global.ssl.fastly.net/_static/f0c7c026a4b2a3d2b287ae2d012c9924/sentry/dist/vendor.js",
893 "line_number": 24,
894 "column_number": 66270,
895 "script_sample": "",
896 "disposition": "enforce"
897 }
898 }
899 "###);
900 }
901
902 #[test]
903 fn test_csp_sample_alias() {
904 let json = r#"{
905 "csp-report": {
906 "document-uri": "http://example.com/foo",
907 "violated-directive": "style-src http://cdn.example.com",
908 "effective-directive": "style-src",
909 "sample": "console.log(\"lo\")"
910 }
911 }"#;
912
913 let mut event = Event::default();
914 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
915 assert_annotated_snapshot!(event.csp, @r#"
916 {
917 "effective_directive": "style-src",
918 "blocked_uri": "self",
919 "document_uri": "http://example.com/foo",
920 "violated_directive": "style-src http://cdn.example.com",
921 "script_sample": "console.log(\"lo\")"
922 }
923 "#);
924 }
925
926 #[test]
927 fn test_csp_culprit_0() {
928 let json = r#"{
929 "csp-report": {
930 "document-uri": "http://example.com/foo",
931 "violated-directive": "style-src http://cdn.example.com",
932 "effective-directive": "style-src"
933 }
934 }"#;
935
936 let mut event = Event::default();
937 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
938 insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://cdn.example.com""###);
939 }
940
941 #[test]
942 fn test_csp_culprit_1() {
943 let json = r#"{
944 "csp-report": {
945 "document-uri": "http://example.com/foo",
946 "violated-directive": "style-src cdn.example.com",
947 "effective-directive": "style-src"
948 }
949 }"#;
950
951 let mut event = Event::default();
952 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
953 insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
954 }
955
956 #[test]
957 fn test_csp_culprit_2() {
958 let json = r#"{
959 "csp-report": {
960 "document-uri": "https://example.com/foo",
961 "violated-directive": "style-src cdn.example.com",
962 "effective-directive": "style-src"
963 }
964 }"#;
965
966 let mut event = Event::default();
967 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
968 insta::assert_debug_snapshot!(event.culprit, @r###""style-src cdn.example.com""###);
969 }
970
971 #[test]
972 fn test_csp_culprit_3() {
973 let json = r#"{
974 "csp-report": {
975 "document-uri": "http://example.com/foo",
976 "violated-directive": "style-src https://cdn.example.com",
977 "effective-directive": "style-src"
978 }
979 }"#;
980
981 let mut event = Event::default();
982 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
983 insta::assert_debug_snapshot!(event.culprit, @r###""style-src https://cdn.example.com""###);
984 }
985
986 #[test]
987 fn test_csp_culprit_4() {
988 let json = r#"{
989 "csp-report": {
990 "document-uri": "http://example.com/foo",
991 "violated-directive": "style-src http://example.com",
992 "effective-directive": "style-src"
993 }
994 }"#;
995
996 let mut event = Event::default();
997 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
998 insta::assert_debug_snapshot!(event.culprit, @r###""style-src 'self'""###);
999 }
1000
1001 #[test]
1002 fn test_csp_culprit_5() {
1003 let json = r#"{
1004 "csp-report": {
1005 "document-uri": "http://example.com/foo",
1006 "violated-directive": "style-src http://example2.com example.com",
1007 "effective-directive": "style-src"
1008 }
1009 }"#;
1010
1011 let mut event = Event::default();
1012 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1013 insta::assert_debug_snapshot!(event.culprit, @r###""style-src http://example2.com 'self'""###);
1014 }
1015
1016 #[test]
1017 fn test_csp_culprit_uri_without_scheme() {
1018 let json = r#"{
1020 "csp-report": {
1021 "document-uri": "example.com",
1022 "violated-directive": "style-src example2.com"
1023 }
1024 }"#;
1025
1026 let mut event = Event::default();
1027 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1028 insta::assert_debug_snapshot!(event.culprit, @r###""style-src example2.com""###);
1029 }
1030
1031 #[test]
1032 fn test_csp_tags_stripe() {
1033 let json = r#"{
1037 "csp-report": {
1038 "document-uri": "https://example.com",
1039 "blocked-uri": "https://api.stripe.com/v1/tokens?card[number]=xxx",
1040 "effective-directive": "script-src"
1041 }
1042 }"#;
1043
1044 let mut event = Event::default();
1045 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1046 insta::assert_debug_snapshot!(event.tags, @r###"
1047 Tags(
1048 PairList(
1049 [
1050 TagEntry(
1051 "effective-directive",
1052 "script-src",
1053 ),
1054 TagEntry(
1055 "blocked-uri",
1056 "https://api.stripe.com/v1/tokens",
1057 ),
1058 TagEntry(
1059 "blocked-host",
1060 "api.stripe.com",
1061 ),
1062 ],
1063 ),
1064 )
1065 "###);
1066 }
1067
1068 #[test]
1069 fn test_csp_get_message_0() {
1070 let json = r#"{
1071 "csp-report": {
1072 "document-uri": "http://example.com/foo",
1073 "effective-directive": "img-src",
1074 "blocked-uri": "http://google.com/foo"
1075 }
1076 }"#;
1077
1078 let mut event = Event::default();
1079 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1080 let message = &event.logentry.value().unwrap().formatted;
1081 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'image' from 'google.com'""###);
1082 }
1083
1084 #[test]
1085 fn test_csp_get_message_1() {
1086 let json = r#"{
1087 "csp-report": {
1088 "document-uri": "http://example.com/foo",
1089 "effective-directive": "style-src",
1090 "blocked-uri": ""
1091 }
1092 }"#;
1093
1094 let mut event = Event::default();
1095 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1096 let message = &event.logentry.value().unwrap().formatted;
1097 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked inline 'style'""###);
1098 }
1099
1100 #[test]
1101 fn test_csp_get_message_2() {
1102 let json = r#"{
1103 "csp-report": {
1104 "document-uri": "http://example.com/foo",
1105 "effective-directive": "script-src",
1106 "blocked-uri": "",
1107 "violated-directive": "script-src 'unsafe-inline'"
1108 }
1109 }"#;
1110
1111 let mut event = Event::default();
1112 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1113 let message = &event.logentry.value().unwrap().formatted;
1114 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe inline 'script'""###);
1115 }
1116
1117 #[test]
1118 fn test_csp_get_message_3() {
1119 let json = r#"{
1120 "csp-report": {
1121 "document-uri": "http://example.com/foo",
1122 "effective-directive": "script-src",
1123 "blocked-uri": "",
1124 "violated-directive": "script-src 'unsafe-eval'"
1125 }
1126 }"#;
1127
1128 let mut event = Event::default();
1129 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1130 let message = &event.logentry.value().unwrap().formatted;
1131 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe eval() 'script'""###);
1132 }
1133
1134 #[test]
1135 fn test_csp_get_message_4() {
1136 let json = r#"{
1137 "csp-report": {
1138 "document-uri": "http://example.com/foo",
1139 "effective-directive": "script-src",
1140 "blocked-uri": "",
1141 "violated-directive": "script-src example.com"
1142 }
1143 }"#;
1144
1145 let mut event = Event::default();
1146 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1147 let message = &event.logentry.value().unwrap().formatted;
1148 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked unsafe (eval() or inline) 'script'""###);
1149 }
1150
1151 #[test]
1152 fn test_csp_get_message_5() {
1153 let json = r#"{
1154 "csp-report": {
1155 "document-uri": "http://example.com/foo",
1156 "effective-directive": "script-src",
1157 "blocked-uri": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"
1158 }
1159 }"#;
1160
1161 let mut event = Event::default();
1162 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1163 let message = &event.logentry.value().unwrap().formatted;
1164 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1165 }
1166
1167 #[test]
1168 fn test_csp_get_message_6() {
1169 let json = r#"{
1170 "csp-report": {
1171 "document-uri": "http://example.com/foo",
1172 "effective-directive": "script-src",
1173 "blocked-uri": "data"
1174 }
1175 }"#;
1176
1177 let mut event = Event::default();
1178 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1179 let message = &event.logentry.value().unwrap().formatted;
1180 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'data:'""###);
1181 }
1182
1183 #[test]
1184 fn test_csp_get_message_7() {
1185 let json = r#"{
1186 "csp-report": {
1187 "document-uri": "http://example.com/foo",
1188 "effective-directive": "style-src-elem",
1189 "blocked-uri": "http://fonts.google.com/foo"
1190 }
1191 }"#;
1192
1193 let mut event = Event::default();
1194 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1195 let message = &event.logentry.value().unwrap().formatted;
1196 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'fonts.google.com'""###);
1197 }
1198
1199 #[test]
1200 fn test_csp_get_message_8() {
1201 let json = r#"{
1202 "csp-report": {
1203 "document-uri": "http://example.com/foo",
1204 "effective-directive": "script-src-elem",
1205 "blocked-uri": "http://cdn.ajaxapis.com/foo"
1206 }
1207 }"#;
1208
1209 let mut event = Event::default();
1210 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1211 let message = &event.logentry.value().unwrap().formatted;
1212 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'script' from 'cdn.ajaxapis.com'""###);
1213 }
1214
1215 #[test]
1216 fn test_csp_get_message_9() {
1217 let json = r#"{
1218 "csp-report": {
1219 "document-uri": "http://notlocalhost:8000/",
1220 "effective-directive": "style-src",
1221 "blocked-uri": "http://notlocalhost:8000/lol.css"
1222 }
1223 }"#;
1224
1225 let mut event = Event::default();
1226 Csp::apply_to_event(json.as_bytes(), &mut event).unwrap();
1227 let message = &event.logentry.value().unwrap().formatted;
1228 insta::assert_debug_snapshot!(message.as_str().unwrap(), @r###""Blocked 'style' from 'notlocalhost:8000'""###);
1229 }
1230
1231 #[test]
1232 fn test_security_report_type_deserializer_recognizes_csp_reports() {
1233 let csp_report_text = r#"{
1234 "csp-report": {
1235 "document-uri": "https://example.com/foo/bar",
1236 "referrer": "https://www.google.com/",
1237 "violated-directive": "default-src self",
1238 "original-policy": "default-src self; report-uri /csp-hotline.php",
1239 "blocked-uri": "http://evilhackerscripts.com"
1240 }
1241 }"#;
1242
1243 let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1244 assert_eq!(report_type, Some(SecurityReportType::Csp));
1245 }
1246
1247 #[test]
1248 fn test_security_report_type_deserializer_recognizes_csp_violations_reports() {
1249 let csp_report_text = r#"{
1250 "age":0,
1251 "body":{
1252 "blockedURL":"https://example.com/tst/media/7_del.png",
1253 "disposition":"enforce",
1254 "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1255 "effectiveDirective":"img-src",
1256 "lineNumber":9,
1257 "originalPolicy":"default-src 'none'; report-to endpoint-csp;",
1258 "referrer":"https://example.com/test229/",
1259 "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1260 "statusCode":0
1261 },
1262 "type":"csp-violation",
1263 "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d",
1264 "user_agent":"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.88 Safari/537.36"
1265 }"#;
1266
1267 let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap();
1268 assert_eq!(report_type, Some(SecurityReportType::Csp));
1269 }
1270
1271 #[test]
1272 fn test_security_report_type_deserializer_rejects_expect_ct_reports() {
1273 let expect_ct_report_text = r#"{
1275 "expect-ct-report": {
1276 "date-time": "2014-04-06T13:00:50Z",
1277 "hostname": "www.example.com",
1278 "port": 443,
1279 "effective-expiration-date": "2014-05-01T12:40:50Z",
1280 "served-certificate-chain": [
1281 "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1282 ],
1283 "validated-certificate-chain": [
1284 "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"
1285 ],
1286 "scts": [
1287 {
1288 "version": 1,
1289 "status": "invalid",
1290 "source": "embedded",
1291 "serialized_sct": "ABCD=="
1292 }
1293 ]
1294 }
1295 }"#;
1296
1297 let report_type = SecurityReportType::from_json(expect_ct_report_text.as_bytes()).unwrap();
1298 assert_eq!(report_type, None);
1299 }
1300
1301 #[test]
1302 fn test_security_report_type_deserializer_rejects_expect_staple_reports() {
1303 let expect_staple_report_text = r#"{
1305 "expect-staple-report": {
1306 "date-time": "2014-04-06T13:00:50Z",
1307 "hostname": "www.example.com",
1308 "port": 443,
1309 "response-status": "ERROR_RESPONSE",
1310 "cert-status": "REVOKED",
1311 "effective-expiration-date": "2014-05-01T12:40:50Z",
1312 "served-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"],
1313 "validated-certificate-chain": ["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]
1314 }
1315 }"#;
1316 let report_type =
1317 SecurityReportType::from_json(expect_staple_report_text.as_bytes()).unwrap();
1318 assert_eq!(report_type, None);
1319 }
1320
1321 #[test]
1322 fn test_security_report_type_deserializer_rejects_hpkp_reports() {
1323 let hpkp_report_text = r#"{
1325 "date-time": "2014-04-06T13:00:50Z",
1326 "hostname": "www.example.com",
1327 "port": 443,
1328 "effective-expiration-date": "2014-05-01T12:40:50Z",
1329 "include-subdomains": false,
1330 "served-certificate-chain": [
1331 "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
1332 ],
1333 "validated-certificate-chain": [
1334 "-----BEGIN CERTIFICATE-----\n MIIEBDCCAuygAwIBAgIDAjppMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVT\n... -----END CERTIFICATE-----"
1335 ],
1336 "known-pins": [
1337 "pin-sha256=\"d6qzRu9zOECb90Uez27xWltNsj0e1Md7GkYYkVoZWmM=\"",
1338 "pin-sha256=\"E9CZ9INDbd+2eRQozYqqbQ2yXLVKB9+xcprMF+44U1g=\""
1339 ]
1340 }"#;
1341
1342 let report_type = SecurityReportType::from_json(hpkp_report_text.as_bytes()).unwrap();
1343 assert_eq!(report_type, None);
1344 }
1345
1346 #[test]
1347 fn test_effective_directive_from_violated_directive_single() {
1348 let csp_raw: CspRaw =
1350 serde_json::from_str(r#"{"violated-directive":"default-src"}"#).unwrap();
1351 assert!(matches!(
1352 csp_raw.effective_directive(),
1353 Ok(CspDirective::DefaultSrc)
1354 ));
1355 }
1356}