//! CSP violation report intake. //! //! The one piece of client-side visibility the platform keeps: it reports on the //! page, not the person. No cookies, no identifiers, no request unless a policy //! is actually violated. A violation fired on every production page load once and //! the only reason anyone noticed was a human opening a browser console; this is //! the channel that would have said so. //! //! The endpoint is unauthenticated by necessity (the browser posts it, not the //! session), so it is rate limited per IP and body capped at the router. Both //! wire formats are accepted: the legacy `report-uri` shape //! (`{"csp-report": {...}}`, content type `application/csp-report`) and the //! Reporting API `report-to` shape (an array of reports, content type //! `application/reports+json`). Anything unparseable is dropped with a 204 — //! this is telemetry, and arguing with a browser about it gains nothing. use axum::{body::Bytes, http::StatusCode}; use serde_json::Value; /// Longest field value written to the log. Report fields are attacker-influenced /// (a blocked URL can be arbitrarily long), so they are truncated rather than /// trusted to be sane. const MAX_FIELD_LEN: usize = 512; /// Most violations logged from a single report body. A Reporting API POST may /// batch, and a page in a redirect loop can batch a lot. const MAX_REPORTS_PER_BODY: usize = 8; pub(super) async fn report_csp_violation(body: Bytes) -> StatusCode { let Ok(json) = serde_json::from_slice::(&body) else { return StatusCode::NO_CONTENT; }; match &json { // report-to: a batch of reports, each with the violation under `body`. Value::Array(reports) => { for report in reports.iter().take(MAX_REPORTS_PER_BODY) { if let Some(violation) = report.get("body") { log_violation(violation, "report-to"); } } } // report-uri: a single violation under `csp-report`. Value::Object(map) => { if let Some(violation) = map.get("csp-report") { log_violation(violation, "report-uri"); } } _ => {} } StatusCode::NO_CONTENT } /// Pull the fields worth having out of either wire format. The Reporting API /// renamed every one of them (`blocked-uri` became `blockedURL`, and so on), so /// each lookup tries both spellings. fn log_violation(violation: &Value, format: &'static str) { let field = |names: &[&str]| -> String { names .iter() .find_map(|name| violation.get(*name).and_then(Value::as_str)) .map(truncate) .unwrap_or_default() }; let directive = field(&[ "effectiveDirective", "effective-directive", "violated-directive", ]); let blocked = field(&["blockedURL", "blocked-uri"]); let document = field(&["documentURL", "document-uri"]); // Report-Only violations are the expected, informational ones: the candidate // policy is deliberately tighter than what is enforced. Enforced violations // mean something on the page actually broke, or someone is probing. let disposition = field(&["disposition"]); tracing::warn!( target: "csp_violation", format, disposition = %disposition, directive = %directive, blocked = %blocked, document = %document, "CSP violation reported" ); } fn truncate(value: &str) -> String { if value.len() <= MAX_FIELD_LEN { return value.to_string(); } let mut end = MAX_FIELD_LEN; while !value.is_char_boundary(end) { end -= 1; } format!("{}...", &value[..end]) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn accepts_report_uri_shape() { let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js"}}"#; assert_eq!( report_csp_violation(Bytes::from_static(body)).await, StatusCode::NO_CONTENT ); } #[tokio::test] async fn accepts_report_to_batch() { let body = br#"[{"type":"csp-violation","body":{"documentURL":"https://makenot.work/","effectiveDirective":"style-src","blockedURL":"inline","disposition":"report"}}]"#; assert_eq!( report_csp_violation(Bytes::from_static(body)).await, StatusCode::NO_CONTENT ); } #[tokio::test] async fn drops_garbage_without_erroring() { assert_eq!( report_csp_violation(Bytes::from_static(b"not json at all")).await, StatusCode::NO_CONTENT ); assert_eq!( report_csp_violation(Bytes::from_static(b"{}")).await, StatusCode::NO_CONTENT ); } #[test] fn truncates_long_fields_on_a_char_boundary() { let long = "\u{e9}".repeat(MAX_FIELD_LEN); let out = truncate(&long); assert!(out.ends_with("...")); assert!(out.len() < long.len()); } }