Skip to main content

max / makenotwork

8.9 KB · 229 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 #[test]
160 fn default_filter_actually_records_a_violation() {
161 // Capture through the shared subscriber rather than a scoped one: the
162 // two tests above hit this same callsite without capturing, and
163 // callsite interest is cached process-wide, so a per-test subscriber
164 // made this assertion depend on which test ran first. See
165 // `crate::test_tracing`.
166 let capture = crate::test_tracing::Capture::start();
167
168 let body = br#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"style-src","blocked-uri":"inline"}}"#;
169 log_violation(
170 &serde_json::from_slice::<Value>(body).unwrap()["csp-report"],
171 "report-uri",
172 );
173
174 let logged = capture.logged();
175 assert!(
176 logged.contains("CSP violation reported"),
177 "the default filter dropped the violation; nothing would reach prod logs. got: {logged:?}"
178 );
179 assert!(
180 logged.contains("style-src"),
181 "directive missing: {logged:?}"
182 );
183 }
184
185 /// The Report-Only policy fires on ordinary traffic on purpose (it drops
186 /// `'unsafe-inline'` from `style-src` while the templates still need it),
187 /// so alerting on it would page daily about known work. Only `enforce`
188 /// counts, and a report with no `disposition` is treated as Report-Only.
189 #[test]
190 fn only_enforced_violations_reach_the_alert_path() {
191 let enforced = serde_json::json!({
192 "documentURL": "https://makenot.work/",
193 "effectiveDirective": "script-src",
194 "blockedURL": "https://evil.example/x.js",
195 "disposition": "enforce",
196 });
197 let report_only = serde_json::json!({
198 "documentURL": "https://makenot.work/",
199 "effectiveDirective": "style-src",
200 "blockedURL": "inline",
201 "disposition": "report",
202 });
203 // Legacy report-uri: no disposition field at all.
204 let legacy = serde_json::json!({
205 "document-uri": "https://makenot.work/",
206 "violated-directive": "style-src",
207 "blocked-uri": "inline",
208 });
209
210 let disposition = |v: &Value| -> String {
211 v.get("disposition")
212 .and_then(Value::as_str)
213 .unwrap_or_default()
214 .to_string()
215 };
216 assert_eq!(disposition(&enforced), "enforce");
217 assert_ne!(disposition(&report_only), "enforce");
218 assert_ne!(disposition(&legacy), "enforce");
219 }
220
221 #[test]
222 fn truncates_long_fields_on_a_char_boundary() {
223 let long = "\u{e9}".repeat(MAX_FIELD_LEN);
224 let out = truncate(&long);
225 assert!(out.ends_with("..."));
226 assert!(out.len() < long.len());
227 }
228 }
229