Skip to main content

max / makenotwork

5.0 KB · 143 lines History Blame Raw
1 //! CSP violation report intake.
2 //!
3 //! The one piece of client-side visibility the platform keeps: it reports on the
4 //! page, not the person. No cookies, no identifiers, no request unless a policy
5 //! is actually violated. A violation fired on every production page load once and
6 //! the only reason anyone noticed was a human opening a browser console; this is
7 //! the channel that would have said so.
8 //!
9 //! The endpoint is unauthenticated by necessity (the browser posts it, not the
10 //! session), so it is rate limited per IP and body capped at the router. Both
11 //! wire formats are accepted: the legacy `report-uri` shape
12 //! (`{"csp-report": {...}}`, content type `application/csp-report`) and the
13 //! Reporting API `report-to` shape (an array of reports, content type
14 //! `application/reports+json`). Anything unparseable is dropped with a 204 —
15 //! this is telemetry, and arguing with a browser about it gains nothing.
16
17 use axum::{body::Bytes, http::StatusCode};
18 use serde_json::Value;
19
20 /// Longest field value written to the log. Report fields are attacker-influenced
21 /// (a blocked URL can be arbitrarily long), so they are truncated rather than
22 /// trusted to be sane.
23 const MAX_FIELD_LEN: usize = 512;
24
25 /// Most violations logged from a single report body. A Reporting API POST may
26 /// batch, and a page in a redirect loop can batch a lot.
27 const MAX_REPORTS_PER_BODY: usize = 8;
28
29 pub(super) async fn report_csp_violation(body: Bytes) -> StatusCode {
30 let Ok(json) = serde_json::from_slice::<Value>(&body) else {
31 return StatusCode::NO_CONTENT;
32 };
33
34 match &json {
35 // report-to: a batch of reports, each with the violation under `body`.
36 Value::Array(reports) => {
37 for report in reports.iter().take(MAX_REPORTS_PER_BODY) {
38 if let Some(violation) = report.get("body") {
39 log_violation(violation, "report-to");
40 }
41 }
42 }
43 // report-uri: a single violation under `csp-report`.
44 Value::Object(map) => {
45 if let Some(violation) = map.get("csp-report") {
46 log_violation(violation, "report-uri");
47 }
48 }
49 _ => {}
50 }
51
52 StatusCode::NO_CONTENT
53 }
54
55 /// Pull the fields worth having out of either wire format. The Reporting API
56 /// renamed every one of them (`blocked-uri` became `blockedURL`, and so on), so
57 /// each lookup tries both spellings.
58 fn log_violation(violation: &Value, format: &'static str) {
59 let field = |names: &[&str]| -> String {
60 names
61 .iter()
62 .find_map(|name| violation.get(*name).and_then(Value::as_str))
63 .map(truncate)
64 .unwrap_or_default()
65 };
66
67 let directive = field(&[
68 "effectiveDirective",
69 "effective-directive",
70 "violated-directive",
71 ]);
72 let blocked = field(&["blockedURL", "blocked-uri"]);
73 let document = field(&["documentURL", "document-uri"]);
74 // Report-Only violations are the expected, informational ones: the candidate
75 // policy is deliberately tighter than what is enforced. Enforced violations
76 // mean something on the page actually broke, or someone is probing.
77 let disposition = field(&["disposition"]);
78
79 tracing::warn!(
80 target: "csp_violation",
81 format,
82 disposition = %disposition,
83 directive = %directive,
84 blocked = %blocked,
85 document = %document,
86 "CSP violation reported"
87 );
88 }
89
90 fn truncate(value: &str) -> String {
91 if value.len() <= MAX_FIELD_LEN {
92 return value.to_string();
93 }
94 let mut end = MAX_FIELD_LEN;
95 while !value.is_char_boundary(end) {
96 end -= 1;
97 }
98 format!("{}...", &value[..end])
99 }
100
101 #[cfg(test)]
102 mod tests {
103 use super::*;
104
105 #[tokio::test]
106 async fn accepts_report_uri_shape() {
107 let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js"}}"#;
108 assert_eq!(
109 report_csp_violation(Bytes::from_static(body)).await,
110 StatusCode::NO_CONTENT
111 );
112 }
113
114 #[tokio::test]
115 async fn accepts_report_to_batch() {
116 let body = br#"[{"type":"csp-violation","body":{"documentURL":"https://makenot.work/","effectiveDirective":"style-src","blockedURL":"inline","disposition":"report"}}]"#;
117 assert_eq!(
118 report_csp_violation(Bytes::from_static(body)).await,
119 StatusCode::NO_CONTENT
120 );
121 }
122
123 #[tokio::test]
124 async fn drops_garbage_without_erroring() {
125 assert_eq!(
126 report_csp_violation(Bytes::from_static(b"not json at all")).await,
127 StatusCode::NO_CONTENT
128 );
129 assert_eq!(
130 report_csp_violation(Bytes::from_static(b"{}")).await,
131 StatusCode::NO_CONTENT
132 );
133 }
134
135 #[test]
136 fn truncates_long_fields_on_a_char_boundary() {
137 let long = "\u{e9}".repeat(MAX_FIELD_LEN);
138 let out = truncate(&long);
139 assert!(out.ends_with("..."));
140 assert!(out.len() < long.len());
141 }
142 }
143