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 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 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 let "'none'" | "'self'" | "'unsafe-inline'" | "'unsafe-eval'" = value {
415 return Cow::Borrowed(value);
416 }
417
418 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 return Cow::Borrowed(value);
435 }
436
437 if value == document_uri {
440 return Cow::Borrowed("'self'");
441 }
442
443 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 CspViolation { body: CspRaw },
527}
528
529#[derive(Clone, Debug, PartialEq, Deserialize)]
531#[serde(rename_all = "kebab-case")]
532enum CspViolationType {
533 CspViolation,
534 #[serde(other)]
535 Other,
536}
537
538#[derive(Clone, Debug, Default, PartialEq, Empty, FromValue, IntoValue, ProcessValue)]
550pub struct Csp {
551 #[metastructure(pii = "true")]
553 pub effective_directive: Annotated<String>,
554 #[metastructure(pii = "true")]
556 pub blocked_uri: Annotated<String>,
557 #[metastructure(pii = "true")]
559 pub document_uri: Annotated<String>,
560 pub original_policy: Annotated<String>,
562 #[metastructure(pii = "true")]
564 pub referrer: Annotated<String>,
565 pub status_code: Annotated<u64>,
567 pub violated_directive: Annotated<String>,
569 #[metastructure(pii = "maybe")]
571 pub source_file: Annotated<String>,
572 pub line_number: Annotated<u64>,
574 pub column_number: Annotated<u64>,
576 #[metastructure(pii = "maybe")]
579 pub script_sample: Annotated<String>,
580 pub disposition: Annotated<String>,
582 #[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 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 assert_eq!(normalize_uri(""), "'self'");
665 assert_eq!(normalize_uri("self"), "'self'");
666
667 assert_eq!(normalize_uri("data"), "data:");
669 assert_eq!(normalize_uri("http"), "http://");
670
671 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 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 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 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 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 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 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 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,
1256 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 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 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 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 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 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 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 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 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 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 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 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 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}