max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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; |
| @@ -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 | + | } |