Skip to content

Commit

Permalink
feat(csp): Add support for Reporting API (#3277)
Browse files Browse the repository at this point in the history
This PR adds support for [Reporting
API](https://developer.mozilla.org/en-US/docs/Web/API/Reporting_API) to
[CSP security
reports](https://developer.mozilla.org/en-US/docs/Web/API/CSPViolationReportBody).

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.

Since we also want to support old format, I've tried to minimize the
changes and re-used as much code as I could.
  • Loading branch information
olksdr authored Mar 20, 2024
1 parent bfa97f6 commit 92d9605
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 25 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
125 changes: 112 additions & 13 deletions relay-event-schema/src/protocol/security_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "originalPolicy",
alias = "original-policy"
)]
original_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
referrer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "statusCode",
alias = "status-code"
)]
status_code: Option<u64>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "lineNumber",
alias = "line-number"
)]
line_number: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "columnNumber",
alias = "column-number"
)]
column_number: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
alias = "scriptSample",
alias = "script-sample"
)]
script_sample: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
disposition: Option<String>,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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::<CspReportRaw>(data)?;
let raw_csp = raw_report.csp_report;
let variant = serde_json::from_slice::<CspVariant>(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)?;
Expand Down Expand Up @@ -1070,6 +1141,8 @@ impl SecurityReportType {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct SecurityReport {
#[serde(rename = "type")]
ty: Option<CspViolationType>,
csp_report: Option<IgnoredAny>,
known_pins: Option<IgnoredAny>,
expect_staple_report: Option<IgnoredAny>,
Expand All @@ -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() {
Expand Down Expand Up @@ -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#"{
Expand Down
41 changes: 29 additions & 12 deletions relay-server/src/endpoints/security_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -30,26 +31,42 @@ struct SecurityReportParams {
}

impl SecurityReportParams {
fn extract_envelope(self) -> Result<Box<Envelope>, 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<Box<Envelope>, 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::<Vec<&RawValue>>(&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)
}
Expand Down
14 changes: 14 additions & 0 deletions relay-server/src/services/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -297,6 +310,7 @@ impl ProcessingGroup {
} else {
ProcessingGroup::Error
};

grouped_envelopes.push((
group,
Envelope::from_parts(headers.clone(), require_event_items),
Expand Down
68 changes: 68 additions & 0 deletions tests/integration/test_security_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down

0 comments on commit 92d9605

Please sign in to comment.