//! CSP violation reporting: the headers that ask for reports, and the endpoint //! that receives them. //! //! The control exists because a CSP violation once fired on every production //! page load and was found by a human opening a browser console. Two things have //! to hold for that not to repeat: every page must carry a reporting //! destination, and the destination must survive being posted to by anyone, //! since the browser sends it with no session. use crate::harness::TestHarness; /// Every response on the main host carries both reporting mechanisms. `report-to` /// is current, `report-uri` is deprecated but is all Safari reads, so dropping /// either one silently loses a browser's worth of coverage. #[tokio::test] async fn pages_carry_both_reporting_mechanisms() { let mut h = TestHarness::new().await; let resp = h.client.get("/").await; let csp = resp.header("content-security-policy").unwrap_or_default(); assert!(csp.contains("report-to mnw-csp"), "csp: {csp}"); assert!(csp.contains("report-uri "), "csp: {csp}"); let endpoints = resp.header("reporting-endpoints").unwrap_or_default(); assert!( endpoints.contains("mnw-csp=\"") && endpoints.contains("/api/csp-report"), "reporting-endpoints: {endpoints}" ); } /// The Report-Only policy is the candidate, not a copy: it drops /// `'unsafe-inline'` from style-src, which is the tightening the enforced policy /// still owes. If these two ever become identical the channel stops telling us /// anything. #[tokio::test] async fn report_only_policy_is_tighter_than_the_enforced_one() { let mut h = TestHarness::new().await; let resp = h.client.get("/").await; let enforced = resp.header("content-security-policy").unwrap_or_default(); let candidate = resp .header("content-security-policy-report-only") .unwrap_or_default(); assert!( enforced.contains("style-src 'self' 'unsafe-inline'"), "enforced: {enforced}" ); assert!( candidate.contains("style-src 'self';"), "candidate: {candidate}" ); assert!( !candidate.contains("'unsafe-inline'"), "the candidate policy must be tighter, got: {candidate}" ); assert!(candidate.contains("report-to mnw-csp"), "{candidate}"); } /// Embeds are framed on third-party pages, which is exactly where an injection /// would land, so they report too. #[tokio::test] async fn embed_responses_report_as_well() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("csprep", "digital", 500).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; let resp = h .client .get(&format!("/embed/i/{}/button", setup.item_id)) .await; let csp = resp.header("content-security-policy").unwrap_or_default(); assert!(csp.contains("frame-ancestors *"), "csp: {csp}"); assert!(csp.contains("report-to mnw-csp"), "csp: {csp}"); } /// Both wire formats are accepted with no session and no CSRF token, because /// that is how a browser sends them. #[tokio::test] async fn endpoint_accepts_both_report_formats_unauthenticated() { let mut h = TestHarness::new().await; let report_uri = r#"{"csp-report":{"document-uri":"https://makenot.work/","violated-directive":"script-src","blocked-uri":"https://evil.example/x.js"}}"#; let resp = h .client .request_with_headers( "POST", "/api/csp-report", Some(report_uri), &[("content-type", "application/csp-report")], ) .await; assert_eq!(resp.status, 204, "report-uri POST rejected"); let report_to = r#"[{"type":"csp-violation","body":{"documentURL":"https://makenot.work/","effectiveDirective":"style-src","blockedURL":"inline","disposition":"report"}}]"#; let resp = h .client .request_with_headers( "POST", "/api/csp-report", Some(report_to), &[("content-type", "application/reports+json")], ) .await; assert_eq!(resp.status, 204, "report-to POST rejected"); } /// A malformed body is dropped, not argued with: a 4xx would only teach a /// misbehaving client to retry. #[tokio::test] async fn malformed_reports_are_dropped_quietly() { let mut h = TestHarness::new().await; let resp = h .client .request_with_headers( "POST", "/api/csp-report", Some("this is not json"), &[("content-type", "application/csp-report")], ) .await; assert_eq!(resp.status, 204); } /// The endpoint takes unauthenticated POSTs, so the body cap is load-bearing: /// without it this is a log-flood vector. #[tokio::test] async fn oversized_reports_are_refused() { let mut h = TestHarness::new().await; let huge = format!( r#"{{"csp-report":{{"blocked-uri":"{}"}}}}"#, "a".repeat(makenotwork::constants::CSP_REPORT_BODY_LIMIT_BYTES + 1) ); let resp = h .client .request_with_headers( "POST", "/api/csp-report", Some(&huge), &[("content-type", "application/csp-report")], ) .await; assert_eq!( resp.status, 413, "body over the cap should be refused, got {}", resp.status ); }