//! 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" ); // The log target above was the whole control until now, and a log nobody // reads is how the last violation went unnoticed on every page load. Give // it a consumer. // // Only an explicitly enforced violation alerts, and the strictness is the // point. The Report-Only policy shipped alongside the enforced one drops // `'unsafe-inline'` from `style-src` while the templates still use inline // `style=""`, so it fires on ordinary traffic by design; alerting on it // would page about known work in progress. The legacy `report-uri` shape // carries no `disposition` at all, so a Safari report cannot be told apart // from a Report-Only one and is treated as the latter. That loses // Safari-only enforced violations, which is the right way round: every // other browser sends `report-to` with a disposition, so a real enforced // violation still arrives, and the alternative is a daily alert about the // candidate policy. if disposition == "enforce" { crate::security_signals::note_csp_violation(&blocked, &directive, &document); } } 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 ); } /// The endpoint answers 204 whether or not anything is recorded, so every /// other test here passes with the reports going nowhere. That is exactly /// what happened in production: `RUST_LOG` is unset, the default filter /// named only the crate path, and `csp_violation` matched no directive, so /// the deployed control discarded every report it accepted. #[tokio::test] async fn default_filter_actually_records_a_violation() { use std::io::Write; use std::sync::{Arc, Mutex}; use tracing_subscriber::layer::SubscriberExt; #[derive(Clone)] struct Buffer(Arc>>); impl Write for Buffer { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().unwrap().extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buffer { type Writer = Self; fn make_writer(&'a self) -> Self::Writer { self.clone() } } let buffer = Buffer(Arc::new(Mutex::new(Vec::new()))); let subscriber = tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( crate::DEFAULT_LOG_FILTER, )) .with( tracing_subscriber::fmt::layer() .with_ansi(false) .with_writer(buffer.clone()), ); let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"style-src","blocked-uri":"inline"}}"#; tracing::subscriber::with_default(subscriber, || { log_violation( &serde_json::from_slice::(body).unwrap()["csp-report"], "report-uri", ); }); let logged = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap(); assert!( logged.contains("CSP violation reported"), "the default filter dropped the violation; nothing would reach prod logs. got: {logged:?}" ); assert!( logged.contains("style-src"), "directive missing: {logged:?}" ); } /// The Report-Only policy fires on ordinary traffic on purpose (it drops /// `'unsafe-inline'` from `style-src` while the templates still need it), /// so alerting on it would page daily about known work. Only `enforce` /// counts, and a report with no `disposition` is treated as Report-Only. #[test] fn only_enforced_violations_reach_the_alert_path() { let enforced = serde_json::json!({ "documentURL": "https://makenot.work/", "effectiveDirective": "script-src", "blockedURL": "https://evil.example/x.js", "disposition": "enforce", }); let report_only = serde_json::json!({ "documentURL": "https://makenot.work/", "effectiveDirective": "style-src", "blockedURL": "inline", "disposition": "report", }); // Legacy report-uri: no disposition field at all. let legacy = serde_json::json!({ "document-uri": "https://makenot.work/", "violated-directive": "style-src", "blocked-uri": "inline", }); let disposition = |v: &Value| -> String { v.get("disposition") .and_then(Value::as_str) .unwrap_or_default() .to_string() }; assert_eq!(disposition(&enforced), "enforce"); assert_ne!(disposition(&report_only), "enforce"); assert_ne!(disposition(&legacy), "enforce"); } #[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()); } }