Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(csp): Add support for Reporting API #3277

Merged
merged 11 commits into from
Mar 20, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Track metric bucket metadata for Relay internal usage. ([#3254](https://github.com/getsentry/relay/pull/3254))
- Enforce rate limits for standalone spans. ([#3238](https://github.com/getsentry/relay/pull/3238))
- Extract `span.status_code` tag for HTTP spans. ([#3245](https://github.com/getsentry/relay/pull/3245))
- Add support for Reporting API for CSP reports ([#3277](https://github.com/getsentry/relay/pull/3277))

**Bug Fixes**:

Expand Down
122 changes: 108 additions & 14 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,22 @@ 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 },
}

/// Models the content of a CSP report.
///
/// Note this models the older CSP reports (report-uri policy directive).
Expand Down Expand Up @@ -491,9 +546,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 @@ -1069,7 +1131,9 @@ impl SecurityReportType {
pub fn from_json(data: &[u8]) -> Result<Option<Self>, serde_json::Error> {
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct SecurityReport {
struct SecurityReport<'a> {
#[serde(rename = "type", borrow)]
ty: Option<Cow<'a, &'a str>>,
Dav1dde marked this conversation as resolved.
Show resolved Hide resolved
csp_report: Option<IgnoredAny>,
known_pins: Option<IgnoredAny>,
expect_staple_report: Option<IgnoredAny>,
Expand All @@ -1080,6 +1144,12 @@ impl SecurityReportType {

Ok(if helper.csp_report.is_some() {
Some(SecurityReportType::Csp)
} else if let Some(ty) = helper.ty {
if *ty == "csp-violation" {
Dav1dde marked this conversation as resolved.
Show resolved Hide resolved
Some(SecurityReportType::Csp)
} else {
None
}
} else if helper.known_pins.is_some() {
Some(SecurityReportType::Hpkp)
} else if helper.expect_staple_report.is_some() {
Expand Down Expand Up @@ -1857,6 +1927,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
Loading