Skip to main content

max / makenotwork

9.8 KB · 257 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 // The log target above was the whole control until now, and a log nobody
90 // reads is how the last violation went unnoticed on every page load. Give
91 // it a consumer.
92 //
93 // Only an explicitly enforced violation alerts, and the strictness is the
94 // point. The Report-Only policy shipped alongside the enforced one drops
95 // `'unsafe-inline'` from `style-src` while the templates still use inline
96 // `style=""`, so it fires on ordinary traffic by design; alerting on it
97 // would page about known work in progress. The legacy `report-uri` shape
98 // carries no `disposition` at all, so a Safari report cannot be told apart
99 // from a Report-Only one and is treated as the latter. That loses
100 // Safari-only enforced violations, which is the right way round: every
101 // other browser sends `report-to` with a disposition, so a real enforced
102 // violation still arrives, and the alternative is a daily alert about the
103 // candidate policy.
104 if disposition == "enforce" {
105 crate::security_signals::note_csp_violation(&blocked, &directive, &document);
106 }
107 }
108
109 fn truncate(value: &str) -> String {
110 if value.len() <= MAX_FIELD_LEN {
111 return value.to_string();
112 }
113 let mut end = MAX_FIELD_LEN;
114 while !value.is_char_boundary(end) {
115 end -= 1;
116 }
117 format!("{}...", &value[..end])
118 }
119
120 #[cfg(test)]
121 mod tests {
122 use super::*;
123
124 #[tokio::test]
125 async fn accepts_report_uri_shape() {
126 let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js"}}"#;
127 assert_eq!(
128 report_csp_violation(Bytes::from_static(body)).await,
129 StatusCode::NO_CONTENT
130 );
131 }
132
133 #[tokio::test]
134 async fn accepts_report_to_batch() {
135 let body = br#"[{"type":"csp-violation","body":{"documentURL":"https://makenot.work/","effectiveDirective":"style-src","blockedURL":"inline","disposition":"report"}}]"#;
136 assert_eq!(
137 report_csp_violation(Bytes::from_static(body)).await,
138 StatusCode::NO_CONTENT
139 );
140 }
141
142 #[tokio::test]
143 async fn drops_garbage_without_erroring() {
144 assert_eq!(
145 report_csp_violation(Bytes::from_static(b"not json at all")).await,
146 StatusCode::NO_CONTENT
147 );
148 assert_eq!(
149 report_csp_violation(Bytes::from_static(b"{}")).await,
150 StatusCode::NO_CONTENT
151 );
152 }
153
154 /// The endpoint answers 204 whether or not anything is recorded, so every
155 /// other test here passes with the reports going nowhere. That is exactly
156 /// what happened in production: `RUST_LOG` is unset, the default filter
157 /// named only the crate path, and `csp_violation` matched no directive, so
158 /// the deployed control discarded every report it accepted.
159 #[tokio::test]
160 async fn default_filter_actually_records_a_violation() {
161 use std::io::Write;
162 use std::sync::{Arc, Mutex};
163 use tracing_subscriber::layer::SubscriberExt;
164
165 #[derive(Clone)]
166 struct Buffer(Arc<Mutex<Vec<u8>>>);
167 impl Write for Buffer {
168 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
169 self.0.lock().unwrap().extend_from_slice(buf);
170 Ok(buf.len())
171 }
172 fn flush(&mut self) -> std::io::Result<()> {
173 Ok(())
174 }
175 }
176 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buffer {
177 type Writer = Self;
178 fn make_writer(&'a self) -> Self::Writer {
179 self.clone()
180 }
181 }
182
183 let buffer = Buffer(Arc::new(Mutex::new(Vec::new())));
184 let subscriber = tracing_subscriber::registry()
185 .with(tracing_subscriber::EnvFilter::new(
186 crate::DEFAULT_LOG_FILTER,
187 ))
188 .with(
189 tracing_subscriber::fmt::layer()
190 .with_ansi(false)
191 .with_writer(buffer.clone()),
192 );
193
194 let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"style-src","blocked-uri":"inline"}}"#;
195 tracing::subscriber::with_default(subscriber, || {
196 log_violation(
197 &serde_json::from_slice::<Value>(body).unwrap()["csp-report"],
198 "report-uri",
199 );
200 });
201
202 let logged = String::from_utf8(buffer.0.lock().unwrap().clone()).unwrap();
203 assert!(
204 logged.contains("CSP violation reported"),
205 "the default filter dropped the violation; nothing would reach prod logs. got: {logged:?}"
206 );
207 assert!(
208 logged.contains("style-src"),
209 "directive missing: {logged:?}"
210 );
211 }
212
213 /// The Report-Only policy fires on ordinary traffic on purpose (it drops
214 /// `'unsafe-inline'` from `style-src` while the templates still need it),
215 /// so alerting on it would page daily about known work. Only `enforce`
216 /// counts, and a report with no `disposition` is treated as Report-Only.
217 #[test]
218 fn only_enforced_violations_reach_the_alert_path() {
219 let enforced = serde_json::json!({
220 "documentURL": "https://makenot.work/",
221 "effectiveDirective": "script-src",
222 "blockedURL": "https://evil.example/x.js",
223 "disposition": "enforce",
224 });
225 let report_only = serde_json::json!({
226 "documentURL": "https://makenot.work/",
227 "effectiveDirective": "style-src",
228 "blockedURL": "inline",
229 "disposition": "report",
230 });
231 // Legacy report-uri: no disposition field at all.
232 let legacy = serde_json::json!({
233 "document-uri": "https://makenot.work/",
234 "violated-directive": "style-src",
235 "blocked-uri": "inline",
236 });
237
238 let disposition = |v: &Value| -> String {
239 v.get("disposition")
240 .and_then(Value::as_str)
241 .unwrap_or_default()
242 .to_string()
243 };
244 assert_eq!(disposition(&enforced), "enforce");
245 assert_ne!(disposition(&report_only), "enforce");
246 assert_ne!(disposition(&legacy), "enforce");
247 }
248
249 #[test]
250 fn truncates_long_fields_on_a_char_boundary() {
251 let long = "\u{e9}".repeat(MAX_FIELD_LEN);
252 let out = truncate(&long);
253 assert!(out.ends_with("..."));
254 assert!(out.len() < long.len());
255 }
256 }
257