//! Security headers on every response (oxygenhub#222), and the endpoint //! the browser reports Content-Security-Policy violations to. //! //! The policy is sent as `Content-Security-Policy-Report-Only` while the //! pages still carry inline scripts and handlers: browsers report what the //! policy would block, the hub logs it, and nothing breaks. Switching to //! enforcing is one constant ([`ENFORCE`]) once the reports are quiet. //! Responses that set their own policy (report frames, #127; a job's //! HTML artifact) keep theirs, and may be framed as they say. //! Strict-Transport-Security is sent only when `[server] hsts_max_age` is //! set (oxygenhub#231), raised in stages: browsers keep it. use axum::http::{header, HeaderName, HeaderValue}; use axum::response::Response; use std::collections::HashMap; use std::sync::Mutex; /// Where violation reports go. pub const REPORT_PATH: &str = "/csp-report"; /// Enforce the policy rather than only report it. const ENFORCE: bool = false; /// What the pages may load: their own scripts (and WebAssembly, for the /// backdrop), styles (inline too: style attributes are everywhere), images /// from anywhere over https (READMEs show badges), and forms posted only to /// the hub (sign-in providers are reached by links, not forms). const POLICY: &str = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; \ img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; \ base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report; report-to csp"; const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), \ midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), browsing-topics=(), interest-cohort=()"; /// The Strict-Transport-Security value for a site, if it sends one /// (oxygenhub#231): only for an https address and a max-age above 0. pub fn hsts(base_url: &str, max_age: u64) -> Option { (base_url.starts_with("https://") && max_age > 0).then(|| HeaderValue::from_str(&format!("max-age={max_age}")).ok()).flatten() } /// Adds the headers a response does not already carry. pub async fn security_headers( axum::extract::State(hsts): axum::extract::State>, req: axum::extract::Request, next: axum::middleware::Next, ) -> Response { let mut res = next.run(req).await; let h = res.headers_mut(); if let Some(v) = hsts { h.entry(header::STRICT_TRANSPORT_SECURITY).or_insert(v); } let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY); let mut set = |name: HeaderName, value: &'static str| { h.entry(name).or_insert(HeaderValue::from_static(value)); }; set(header::X_CONTENT_TYPE_OPTIONS, "nosniff"); set(header::REFERRER_POLICY, "strict-origin-when-cross-origin"); set(HeaderName::from_static("permissions-policy"), PERMISSIONS); if !own_policy { set(header::X_FRAME_OPTIONS, "DENY"); set(HeaderName::from_static("reporting-endpoints"), "csp=\"/csp-report\""); let name = if ENFORCE { header::CONTENT_SECURITY_POLICY } else { header::CONTENT_SECURITY_POLICY_REPORT_ONLY }; set(name, POLICY); } res } /// One violation, as either report format gives it. #[derive(Debug, PartialEq)] pub struct Violation { pub page: String, pub directive: String, pub blocked: String, pub source: String, } /// The violations in a report body: the older `application/csp-report` /// (`{"csp-report": {...}}`) or the Reporting API's list. pub fn violations(body: &[u8]) -> Vec { let Ok(v) = serde_json::from_slice::(body) else { return vec![] }; let s = |v: &serde_json::Value, keys: &[&str]| { keys.iter().find_map(|k| v.get(*k).and_then(|x| x.as_str().map(String::from).or_else(|| x.as_u64().map(|n| n.to_string())))) }; let one = |r: &serde_json::Value, page: &[&str], dir: &[&str], blocked: &[&str], file: &[&str], line: &[&str]| Violation { page: s(r, page).unwrap_or_default(), directive: s(r, dir).unwrap_or_default(), blocked: s(r, blocked).unwrap_or_default(), source: match (s(r, file), s(r, line)) { (Some(f), Some(l)) => format!("{f}:{l}"), (Some(f), None) => f, _ => String::new(), }, }; if let Some(r) = v.get("csp-report") { return vec![one( r, &["document-uri"], &["effective-directive", "violated-directive"], &["blocked-uri"], &["source-file"], &["line-number"], )]; } v.as_array() .map(|list| { list.iter() .filter(|r| r.get("type").and_then(|t| t.as_str()) == Some("csp-violation")) .filter_map(|r| r.get("body")) .map(|b| one(b, &["documentURL"], &["effectiveDirective"], &["blockedURL"], &["sourceFile"], &["lineNumber"])) .collect() }) .unwrap_or_default() } /// Each distinct violation is logged once an hour, so a busy page does not /// flood the log. static SEEN: Mutex>> = Mutex::new(None); const LOG_EVERY_MS: i64 = 60 * 60_000; fn first_in_a_while(key: String, now_ms: i64) -> bool { let mut seen = SEEN.lock().unwrap(); let seen = seen.get_or_insert_with(HashMap::new); if seen.len() > 1000 { seen.retain(|_, t| now_ms - *t < LOG_EVERY_MS); } match seen.get(&key) { Some(t) if now_ms - t < LOG_EVERY_MS => false, _ => { seen.insert(key, now_ms); true } } } /// `POST /csp-report`: log what the policy would block. pub async fn csp_report(body: axum::body::Bytes) -> axum::http::StatusCode { let clip = |s: &str| s.chars().filter(|c| !c.is_control()).take(200).collect::(); for v in violations(&body) { // The page's path only: a query can hold anything. let page = v.page.split(['?', '#']).next().unwrap_or("").to_string(); let key = format!("{}|{}|{}", v.directive, v.blocked, v.source); if first_in_a_while(key, crate::db::now_ms()) { tracing::warn!("csp would block: {} {} on {} ({})", clip(&v.directive), clip(&v.blocked), clip(&page), clip(&v.source)); } } axum::http::StatusCode::NO_CONTENT } #[cfg(test)] mod tests { use super::*; #[test] fn both_report_formats_are_read() { let old = br#"{"csp-report":{"document-uri":"https://hub.example/a?b","violated-directive":"script-src-elem","blocked-uri":"inline","source-file":"https://hub.example/a","line-number":12}}"#; assert_eq!( violations(old), vec![Violation { page: "https://hub.example/a?b".into(), directive: "script-src-elem".into(), blocked: "inline".into(), source: "https://hub.example/a:12".into() }] ); let new = br#"[{"type":"csp-violation","url":"https://hub.example/x","body":{"documentURL":"https://hub.example/x","effectiveDirective":"img-src","blockedURL":"http://cdn.example/i.png"}},{"type":"deprecation","body":{}}]"#; let v = violations(new); assert_eq!(v.len(), 1); assert_eq!((v[0].directive.as_str(), v[0].blocked.as_str(), v[0].source.as_str()), ("img-src", "http://cdn.example/i.png", "")); assert!(violations(b"not json").is_empty()); } #[test] fn a_violation_is_logged_once_an_hour() { let k = || "script-src|inline|test-only".to_string(); assert!(first_in_a_while(k(), 1_000)); assert!(!first_in_a_while(k(), 2_000)); assert!(first_in_a_while(k(), 1_000 + LOG_EVERY_MS)); } }