Skip to main content

max / makenotwork

Add CSP violation reporting The last CSP violation fired on every production page load and was found by a human opening a browser console. This is the channel that would have said so: report-uri and report-to on every policy (both, because Safari still only reads report-uri), plus a Reporting-Endpoints header. Report-Only ships as the candidate policy rather than a copy of the enforced one: identical except style-src drops 'unsafe-inline', which is the tightening still owed and cannot be made blind while inline style attributes remain in the templates. The intake is unauthenticated by necessity, so it skips CSRF and leans on a per-IP throttle, a 16 KB body cap, and field truncation. Malformed reports get a 204; arguing with a browser only teaches it to retry. Privacy policy now states we run no analytics, and what a report does and does not carry.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 16:59 UTC
Signed with PGP, not checked
Commit: 996dc0081266b471ee1ad3f6c14519201d33c8ff
Parent: d0d38a8
7 files changed, +390 insertions, -31 deletions
@@ -180,6 +180,14 @@
180 180 // can't be hammered anonymously. Burst 60, then 2/sec.
181 181 pub const GUEST_DOWNLOAD_RATE_LIMIT_PER_SEC: u64 = 2;
182 182 pub const GUEST_DOWNLOAD_RATE_LIMIT_BURST: u32 = 60;
183 + // CSP violation reports (public, unauthenticated, browser-posted): burst 20,
184 + // then 1/sec. A page that violates the policy on every load would otherwise let
185 + // any visitor's browser flood the log for free.
186 + pub const CSP_REPORT_RATE_LIMIT_PER_SEC: u64 = 1;
187 + pub const CSP_REPORT_RATE_LIMIT_BURST: u32 = 20;
188 + // A CSP report is a few hundred bytes; the cap exists so the endpoint cannot be
189 + // used to push a megabyte of anything at the log.
190 + pub const CSP_REPORT_BODY_LIMIT_BYTES: usize = 16 * 1024;
183 191 // License key validation (public): burst 20, then 5/sec
184 192 pub const LICENSE_KEY_RATE_LIMIT_MS: u64 = 200;
185 193 pub const LICENSE_KEY_RATE_LIMIT_BURST: u32 = 20;
M server/src/lib.rs +66 -31
@@ -663,6 +663,22 @@
663 663 let mut response = next.run(request).await;
664 664 let headers = response.headers_mut();
665 665
666 + // Violation reporting. `report-to` is the current mechanism and `report-uri`
667 + // the deprecated one, and both are sent because no browser supports both:
668 + // Safari still only reads report-uri. The endpoint is absolute so the
669 + // Reporting-Endpoints value is a valid URL on every parser.
670 + let report_endpoint = format!(
671 + "{}/api/csp-report",
672 + state.config.host_url.trim_end_matches('/')
673 + );
674 + let reporting = format!("; report-uri {report_endpoint}; report-to mnw-csp");
675 + if let Ok(value) = HeaderValue::from_str(&format!("mnw-csp=\"{report_endpoint}\"")) {
676 + headers.insert(
677 + axum::http::header::HeaderName::from_static("reporting-endpoints"),
678 + value,
679 + );
680 + }
681 +
666 682 if is_embed {
667 683 // Embed routes: framable from any origin, but otherwise locked down.
668 684 // `frame-ancestors *` alone (the old value) left default-src/script-src
@@ -675,24 +691,27 @@
675 691 axum::http::header::X_FRAME_OPTIONS,
676 692 HeaderValue::from_static("ALLOWALL"),
677 693 );
678 - headers.insert(
679 - axum::http::header::HeaderName::from_static("content-security-policy"),
680 - HeaderValue::from_static(
681 - // The embedded player loads an external same-origin script
682 - // (/static/embed-item-player.js) and carries no inline handlers,
683 - // so scripts need 'self', not 'unsafe-inline' (which would
684 - // silently block the external file and leave the player dead).
685 - "default-src 'none'; \
686 - img-src 'self' data: https:; \
687 - media-src 'self'; \
688 - style-src 'unsafe-inline'; \
689 - script-src 'self'; \
690 - font-src 'self'; \
691 - base-uri 'none'; \
692 - form-action 'none'; \
693 - frame-ancestors *",
694 - ),
694 + // The embedded player loads an external same-origin script
695 + // (/static/embed-item-player.js) and carries no inline handlers,
696 + // so scripts need 'self', not 'unsafe-inline' (which would
697 + // silently block the external file and leave the player dead).
698 + let embed_csp = format!(
699 + "default-src 'none'; \
700 + img-src 'self' data: https:; \
701 + media-src 'self'; \
702 + style-src 'unsafe-inline'; \
703 + script-src 'self'; \
704 + font-src 'self'; \
705 + base-uri 'none'; \
706 + form-action 'none'; \
707 + frame-ancestors *{reporting}"
695 708 );
709 + if let Ok(value) = HeaderValue::from_str(&embed_csp) {
710 + headers.insert(
711 + axum::http::header::HeaderName::from_static("content-security-policy"),
712 + value,
713 + );
714 + }
696 715 } else {
697 716 // Normal routes: deny framing
698 717 headers.insert(
@@ -727,25 +746,41 @@
727 746 // (the data-action / data-hx-* dispatchers in mnw.js), so any injected
728 747 // markup can no longer execute script. style-src keeps 'unsafe-inline'
729 748 // because inline style="" attributes are still used throughout.
730 - let csp = format!(
731 - "default-src 'self'; \
732 - script-src 'self' https://js.stripe.com; \
733 - style-src 'self' 'unsafe-inline'; \
734 - img-src 'self' data: https:; \
735 - font-src 'self'; \
736 - connect-src 'self' https://api.stripe.com{storage_origins}; \
737 - media-src 'self'{storage_origins}; \
738 - frame-src 'self' https://js.stripe.com {user_pages_origin}; \
739 - base-uri 'self'; \
740 - form-action {form_action}; \
741 - frame-ancestors 'none'"
742 - );
743 - if let Ok(value) = HeaderValue::from_str(&csp) {
749 + let policy = |style_src: &str| {
750 + format!(
751 + "default-src 'self'; \
752 + script-src 'self' https://js.stripe.com; \
753 + style-src {style_src}; \
754 + img-src 'self' data: https:; \
755 + font-src 'self'; \
756 + connect-src 'self' https://api.stripe.com{storage_origins}; \
757 + media-src 'self'{storage_origins}; \
758 + frame-src 'self' https://js.stripe.com {user_pages_origin}; \
759 + base-uri 'self'; \
760 + form-action {form_action}; \
761 + frame-ancestors 'none'{reporting}"
762 + )
763 + };
764 + if let Ok(value) = HeaderValue::from_str(&policy("'self' 'unsafe-inline'")) {
744 765 headers.insert(
745 766 axum::http::header::HeaderName::from_static("content-security-policy"),
746 767 value,
747 768 );
748 769 }
770 + // The candidate policy, reported on but not enforced: identical except
771 + // that style-src drops 'unsafe-inline'. That is the one tightening the
772 + // enforced policy still owes, and it cannot be made blind — inline
773 + // style="" attributes are still used throughout the templates. The
774 + // reports say how many are left and where, and when they stop arriving
775 + // the enforced policy can adopt it. Report-Only is also the safe channel
776 + // for every later tightening, which is why it ships alongside rather
777 + // than instead of.
778 + if let Ok(value) = HeaderValue::from_str(&policy("'self'")) {
779 + headers.insert(
780 + axum::http::header::HeaderName::from_static("content-security-policy-report-only"),
781 + value,
782 + );
783 + }
749 784 }
750 785
751 786 headers.insert(
@@ -24,6 +24,7 @@
24 24 mod creator;
25 25 mod creator_media;
26 26 mod creator_tier_comp;
27 + mod csp_reporting;
27 28 mod csrf_coverage;
28 29 mod custom_domains;
29 30 mod custom_links;
@@ -50,6 +50,9 @@
50 50 - Cross-site tracking or device fingerprinting for identification
51 51 - Third-party tracking data
52 52 - Social media profiles
53 + - Analytics of any kind. We run no analytics product, first-party or third-party, and no page-view or visitor measurement.
54 +
55 + The one thing your browser reports back to us is a Content Security Policy violation: if a page tries to load a script or style the policy forbids, the browser posts the blocked address and the page it happened on. That report carries no cookie, no identifier, and nothing about you, and it is sent only when a violation occurs. It exists so an injected script shows up in our logs instead of going unnoticed.
53 56
54 57 ## How We Use Data
55 58
@@ -21,6 +21,7 @@
21 21 mod categories;
22 22 mod collections;
23 23 mod content_insertions;
24 + mod csp_report;
24 25 mod domains;
25 26 mod exports;
26 27 mod follows;
@@ -66,6 +67,7 @@
66 67
67 68 const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session";
68 69 const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session";
70 + const CSP_REPORT_SKIP: &str = "CSP report: browser-posted, no session";
69 71
70 72 /// Fetch a project and verify the user owns it. Shared by all ownership checks
71 73 /// that go through a project (items, blog posts, direct project access).
@@ -766,7 +768,26 @@
766 768 .route_get("/download/{token}", get(guest_checkout::guest_download))
767 769 .route_layer(GovernorLayer::new(download_rate_limit));
768 770
771 + // CSP violation intake. Posted by the browser with no session and no CSRF
772 + // token, so it skips CSRF by necessity; the compensating controls are the
773 + // per-IP throttle and the body cap, and the handler writes nothing but a log
774 + // line.
775 + let csp_report_rate_limit = crate::helpers::rate_limiter_per_sec(
776 + constants::CSP_REPORT_RATE_LIMIT_PER_SEC,
777 + constants::CSP_REPORT_RATE_LIMIT_BURST,
778 + );
779 + let csp_report_routes = CsrfRouter::new()
780 + .route(
781 + "/api/csp-report",
782 + post_csrf_skip(CSP_REPORT_SKIP, csp_report::report_csp_violation),
783 + )
784 + .layer(axum::extract::DefaultBodyLimit::max(
785 + constants::CSP_REPORT_BODY_LIMIT_BYTES,
786 + ))
787 + .route_layer(GovernorLayer::new(csp_report_rate_limit));
788 +
769 789 write_routes
790 + .merge(csp_report_routes)
770 791 .merge(totp_sensitive_routes)
771 792 .merge(export_routes)
772 793 .merge(key_routes)
@@ -1,0 +1,142 @@
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 + }
@@ -1,0 +1,149 @@
1 + //! CSP violation reporting: the headers that ask for reports, and the endpoint
2 + //! that receives them.
3 + //!
4 + //! The control exists because a CSP violation once fired on every production
5 + //! page load and was found by a human opening a browser console. Two things have
6 + //! to hold for that not to repeat: every page must carry a reporting
7 + //! destination, and the destination must survive being posted to by anyone,
8 + //! since the browser sends it with no session.
9 +
10 + use crate::harness::TestHarness;
11 +
12 + /// Every response on the main host carries both reporting mechanisms. `report-to`
13 + /// is current, `report-uri` is deprecated but is all Safari reads, so dropping
14 + /// either one silently loses a browser's worth of coverage.
15 + #[tokio::test]
16 + async fn pages_carry_both_reporting_mechanisms() {
17 + let mut h = TestHarness::new().await;
18 + let resp = h.client.get("/").await;
19 +
20 + let csp = resp.header("content-security-policy").unwrap_or_default();
21 + assert!(csp.contains("report-to mnw-csp"), "csp: {csp}");
22 + assert!(csp.contains("report-uri "), "csp: {csp}");
23 +
24 + let endpoints = resp.header("reporting-endpoints").unwrap_or_default();
25 + assert!(
26 + endpoints.contains("mnw-csp=\"") && endpoints.contains("/api/csp-report"),
27 + "reporting-endpoints: {endpoints}"
28 + );
29 + }
30 +
31 + /// The Report-Only policy is the candidate, not a copy: it drops
32 + /// `'unsafe-inline'` from style-src, which is the tightening the enforced policy
33 + /// still owes. If these two ever become identical the channel stops telling us
34 + /// anything.
35 + #[tokio::test]
36 + async fn report_only_policy_is_tighter_than_the_enforced_one() {
37 + let mut h = TestHarness::new().await;
38 + let resp = h.client.get("/").await;
39 +
40 + let enforced = resp.header("content-security-policy").unwrap_or_default();
41 + let candidate = resp
42 + .header("content-security-policy-report-only")
43 + .unwrap_or_default();
44 +
45 + assert!(
46 + enforced.contains("style-src 'self' 'unsafe-inline'"),
47 + "enforced: {enforced}"
48 + );
49 + assert!(
50 + candidate.contains("style-src 'self';"),
51 + "candidate: {candidate}"
52 + );
53 + assert!(
54 + !candidate.contains("'unsafe-inline'"),
55 + "the candidate policy must be tighter, got: {candidate}"
56 + );
57 + assert!(candidate.contains("report-to mnw-csp"), "{candidate}");
58 + }
59 +
60 + /// Embeds are framed on third-party pages, which is exactly where an injection
61 + /// would land, so they report too.
62 + #[tokio::test]
63 + async fn embed_responses_report_as_well() {
64 + let mut h = TestHarness::new().await;
65 + let setup = h.create_creator_with_item("csprep", "digital", 500).await;
66 + h.publish_project_and_item(&setup.project_id, &setup.item_id)
67 + .await;
68 +
69 + let resp = h
70 + .client
71 + .get(&format!("/embed/i/{}/button", setup.item_id))
72 + .await;
73 + let csp = resp.header("content-security-policy").unwrap_or_default();
74 + assert!(csp.contains("frame-ancestors *"), "csp: {csp}");
75 + assert!(csp.contains("report-to mnw-csp"), "csp: {csp}");
76 + }
77 +
78 + /// Both wire formats are accepted with no session and no CSRF token, because
79 + /// that is how a browser sends them.
80 + #[tokio::test]
81 + async fn endpoint_accepts_both_report_formats_unauthenticated() {
82 + let mut h = TestHarness::new().await;
83 +
84 + let report_uri = r#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js"}}"#;
85 + let resp = h
86 + .client
87 + .request_with_headers(
88 + "POST",
89 + "/api/csp-report",
90 + Some(report_uri),
91 + &[("content-type", "application/csp-report")],
92 + )
93 + .await;
94 + assert_eq!(resp.status, 204, "report-uri POST rejected");
95 +
96 + let report_to = r#"[{"type":"csp-violation","body":{"documentURL":"https://makenot.work/","effectiveDirective":"style-src","blockedURL":"inline","disposition":"report"}}]"#;
97 + let resp = h
98 + .client
99 + .request_with_headers(
100 + "POST",
101 + "/api/csp-report",
102 + Some(report_to),
103 + &[("content-type", "application/reports+json")],
104 + )
105 + .await;
106 + assert_eq!(resp.status, 204, "report-to POST rejected");
107 + }
108 +
109 + /// A malformed body is dropped, not argued with: a 4xx would only teach a
110 + /// misbehaving client to retry.
111 + #[tokio::test]
112 + async fn malformed_reports_are_dropped_quietly() {
113 + let mut h = TestHarness::new().await;
114 + let resp = h
115 + .client
116 + .request_with_headers(
117 + "POST",
118 + "/api/csp-report",
119 + Some("this is not json"),
120 + &[("content-type", "application/csp-report")],
121 + )
122 + .await;
123 + assert_eq!(resp.status, 204);
124 + }
125 +
126 + /// The endpoint takes unauthenticated POSTs, so the body cap is load-bearing:
127 + /// without it this is a log-flood vector.
128 + #[tokio::test]
129 + async fn oversized_reports_are_refused() {
130 + let mut h = TestHarness::new().await;
131 + let huge = format!(
132 + r#"{{"csp-report":{{"blocked-uri":"{}"}}}}"#,
133 + "a".repeat(makenotwork::constants::CSP_REPORT_BODY_LIMIT_BYTES + 1)
134 + );
135 + let resp = h
136 + .client
137 + .request_with_headers(
138 + "POST",
139 + "/api/csp-report",
140 + Some(&huge),
141 + &[("content-type", "application/csp-report")],
142 + )
143 + .await;
144 + assert_eq!(
145 + resp.status, 413,
146 + "body over the cap should be refused, got {}",
147 + resp.status
148 + );
149 + }