Skip to main content

max / makenotwork

5.3 KB · 150 lines History Blame Raw
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 }
150