diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3f999851..49db530f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +**Features** + +- Add support for Reporting API for CSP reports ([#3277](https://github.com/getsentry/relay/pull/3277)) + **Internal**: - Enable `db.redis` span metrics extraction. ([#3283](https://github.com/getsentry/relay/pull/3283)) diff --git a/relay-event-schema/src/protocol/security_report.rs b/relay-event-schema/src/protocol/security_report.rs index 1aff1003cd..227e059232 100644 --- a/relay-event-schema/src/protocol/security_report.rs +++ b/relay-event-schema/src/protocol/security_report.rs @@ -180,29 +180,68 @@ fn normalize_uri(value: &str) -> Cow<'_, str> { /// /// See `Csp` for meaning of fields. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "kebab-case")] struct CspRaw { - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "effective-directive", + alias = "effectiveDirective" + )] effective_directive: Option, - #[serde(default = "CspRaw::default_blocked_uri")] + #[serde( + default = "CspRaw::default_blocked_uri", + alias = "blockedUrl", + alias = "blocked-uri" + )] blocked_uri: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "documentUrl", + alias = "document-uri" + )] document_uri: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "originalPolicy", + alias = "original-policy" + )] original_policy: Option, #[serde(skip_serializing_if = "Option::is_none")] referrer: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "statusCode", + alias = "status-code" + )] status_code: Option, - #[serde(default = "String::new")] + #[serde( + default = "String::new", + alias = "violatedDirective", + alias = "violated-directive" + )] violated_directive: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "sourceFile", + alias = "source-file" + )] source_file: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "lineNumber", + alias = "line-number" + )] line_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "columnNumber", + alias = "column-number" + )] column_number: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "scriptSample", + alias = "script-sample" + )] script_sample: Option, #[serde(skip_serializing_if = "Option::is_none")] disposition: Option, @@ -442,6 +481,31 @@ struct CspReportRaw { csp_report: CspRaw, } +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(untagged)] +enum CspVariant { + Csp { + #[serde(rename = "csp-report")] + csp_report: CspRaw, + }, + /// Defines CSP report sent through the [Reporting API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API). + /// + /// This contains the [body](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody) + /// with actual report. We currently ignore the additional fields. + /// Reporting API has [slightly different format](https://csplite.com/csp66/#sample-violation-report) for the CSP report body, + /// but the biggest difference that browser sends the CSP reports in batches. + CspViolation { body: CspRaw }, +} + +/// The type of the CSP report which comes through the Reporting API. +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum CspViolationType { + CspViolation, + #[serde(other)] + Other, +} + /// Models the content of a CSP report. /// /// Note this models the older CSP reports (report-uri policy directive). @@ -491,9 +555,16 @@ pub struct Csp { impl Csp { pub fn apply_to_event(data: &[u8], event: &mut Event) -> Result<(), serde_json::Error> { - let raw_report = serde_json::from_slice::(data)?; - let raw_csp = raw_report.csp_report; + let variant = serde_json::from_slice::(data)?; + match variant { + CspVariant::Csp { csp_report } => Csp::extract_report(event, csp_report)?, + CspVariant::CspViolation { body } => Csp::extract_report(event, body)?, + } + + Ok(()) + } + fn extract_report(event: &mut Event, raw_csp: CspRaw) -> Result<(), serde_json::Error> { let effective_directive = raw_csp .effective_directive() .map_err(serde::de::Error::custom)?; @@ -1070,6 +1141,8 @@ impl SecurityReportType { #[derive(Deserialize)] #[serde(rename_all = "kebab-case")] struct SecurityReport { + #[serde(rename = "type")] + ty: Option, csp_report: Option, known_pins: Option, expect_staple_report: Option, @@ -1080,6 +1153,8 @@ impl SecurityReportType { Ok(if helper.csp_report.is_some() { Some(SecurityReportType::Csp) + } else if let Some(CspViolationType::CspViolation) = helper.ty { + Some(SecurityReportType::Csp) } else if helper.known_pins.is_some() { Some(SecurityReportType::Hpkp) } else if helper.expect_staple_report.is_some() { @@ -1857,6 +1932,30 @@ mod tests { assert_eq!(report_type, Some(SecurityReportType::Csp)); } + #[test] + fn test_security_report_type_deserializer_recognizes_csp_violations_reports() { + let csp_report_text = r#"{ + "age":0, + "body":{ + "blockedURL":"https://example.com/tst/media/7_del.png", + "disposition":"enforce", + "documentURL":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "effectiveDirective":"img-src", + "lineNumber":9, + "originalPolicy":"default-src 'none'; report-to endpoint-csp;", + "referrer":"https://example.com/test229/", + "sourceFile":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "statusCode":0 + }, + "type":"csp-violation", + "url":"https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "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" + }"#; + + let report_type = SecurityReportType::from_json(csp_report_text.as_bytes()).unwrap(); + assert_eq!(report_type, Some(SecurityReportType::Csp)); + } + #[test] fn test_security_report_type_deserializer_recognizes_expect_ct_reports() { let expect_ct_report_text = r#"{ diff --git a/relay-server/src/endpoints/security_report.rs b/relay-server/src/endpoints/security_report.rs index e402ca07f3..1c26b9d2ca 100644 --- a/relay-server/src/endpoints/security_report.rs +++ b/relay-server/src/endpoints/security_report.rs @@ -8,6 +8,7 @@ use bytes::Bytes; use relay_config::Config; use relay_event_schema::protocol::EventId; use serde::Deserialize; +use serde_json::value::RawValue; use crate::endpoints::common::{self, BadStoreRequest}; use crate::envelope::{ContentType, Envelope, Item, ItemType}; @@ -30,26 +31,42 @@ struct SecurityReportParams { } impl SecurityReportParams { - fn extract_envelope(self) -> Result, BadStoreRequest> { - let Self { meta, query, body } = self; + fn create_security_item(query: &SecurityReportQuery, item: Bytes) -> Item { + let mut report_item = Item::new(ItemType::RawSecurity); + report_item.set_payload(ContentType::Json, item); - if body.is_empty() { - return Err(BadStoreRequest::EmptyBody); + if let Some(sentry_release) = &query.sentry_release { + report_item.set_header("sentry_release", sentry_release.clone()); } - let mut report_item = Item::new(ItemType::RawSecurity); - report_item.set_payload(ContentType::Json, body); - - if let Some(sentry_release) = query.sentry_release { - report_item.set_header("sentry_release", sentry_release); + if let Some(sentry_environment) = &query.sentry_environment { + report_item.set_header("sentry_environment", sentry_environment.clone()); } - if let Some(sentry_environment) = query.sentry_environment { - report_item.set_header("sentry_environment", sentry_environment); + report_item + } + + fn extract_envelope(self) -> Result, BadStoreRequest> { + let Self { meta, query, body } = self; + + if body.is_empty() { + return Err(BadStoreRequest::EmptyBody); } let mut envelope = Envelope::from_request(Some(EventId::new()), meta); - envelope.add_item(report_item); + let variant = + serde_json::from_slice::>(&body).map_err(BadStoreRequest::InvalidJson); + + if let Ok(items) = variant { + for item in items { + let report_item = + Self::create_security_item(&query, Bytes::from(item.to_owned().to_string())); + envelope.add_item(report_item); + } + } else { + let report_item = Self::create_security_item(&query, body); + envelope.add_item(report_item); + } Ok(envelope) } diff --git a/relay-server/src/services/processor.rs b/relay-server/src/services/processor.rs index d8989135e7..54a41e76ec 100644 --- a/relay-server/src/services/processor.rs +++ b/relay-server/src/services/processor.rs @@ -286,6 +286,19 @@ impl ProcessingGroup { } }; + // Make sure we create separate envelopes for each `RawSecurity` report. + let security_reports_items = envelope + .take_items_by(|i| matches!(i.ty(), &ItemType::RawSecurity)) + .into_iter() + .map(|item| { + let headers = headers.clone(); + let items: SmallVec<[Item; 3]> = smallvec![item.clone()]; + let mut envelope = Envelope::from_parts(headers, items); + envelope.set_event_id(EventId::new()); + (ProcessingGroup::Error, envelope) + }); + grouped_envelopes.extend(security_reports_items); + // Extract all the items which require an event into separate envelope. let require_event_items = envelope.take_items_by(Item::requires_event); if !require_event_items.is_empty() { @@ -297,6 +310,7 @@ impl ProcessingGroup { } else { ProcessingGroup::Error }; + grouped_envelopes.push(( group, Envelope::from_parts(headers.clone(), require_event_items), diff --git a/tests/integration/test_security_report.py b/tests/integration/test_security_report.py index 6b31907a9d..49eaa01baf 100644 --- a/tests/integration/test_security_report.py +++ b/tests/integration/test_security_report.py @@ -95,6 +95,74 @@ def test_security_report_with_processing( assert event == expected_evt +def test_csp_violation_reports_with_processing( + mini_sentry, + relay_with_processing, + events_consumer, +): + # UA parsing introduces higher latency in debug mode + events_consumer = events_consumer(timeout=10) + + proj_id = 42 + relay = relay_with_processing() + mini_sentry.add_full_project_config(proj_id) + + reports = [ + { + "age": 10, + "body": { + "blockedURL": "https://example.net/assets/media/7_del.png", + "disposition": "enforce", + "documentURL": "https://example.net/assets/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "effectiveDirective": "img-src", + "lineNumber": 9, + "originalPolicy": "default-src 'none'; report-to endpoint-csp;", + "referrer": "https://example.net/test229/", + "sourceFile": "https://example.net/assets/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "statusCode": 0, + }, + "type": "csp-violation", + "url": "https://example.com/assets/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "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", + }, + { + "age": 0, + "body": { + "blockedURL": "https://example.com/tst/media/7_del.png", + "disposition": "report", + "documentURL": "https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "effectiveDirective": "img-src", + "lineNumber": 9, + "originalPolicy": "default-src 'none'; report-to endpoint-csp;", + "referrer": "https://example.com/test229/", + "sourceFile": "https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "statusCode": 0, + }, + "type": "csp-violation", + "url": "https://example.com/tst/test_frame.php?ID=229&hash=da964209653e467d337313e51876e27d", + "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", + }, + ] + + relay.send_security_report( + project_id=proj_id, + content_type="application/json; charset=utf-8", + payload=reports, + release="01d5c3165d9fbc5c8bdcf9550a1d6793a80fc02b", + environment="production", + ) + + events = [] + event, _ = events_consumer.get_event() + events.append(event) + event, _ = events_consumer.get_event() + events.append(event) + events_consumer.assert_empty() + + assert any(d for d in events if d["csp"]["disposition"] == "enforce") + assert any(d for d in events if d["csp"]["disposition"] == "report") + + @pytest.mark.parametrize( "test_case", [