Security headers on every response; CSP report-only with a report endpoint d74ce22fa80e
nosniff, strict-origin-when-cross-origin, a Permissions-Policy, and on pages X-Frame-Options DENY with a Content-Security-Policy-Report-Only (self only, wasm-unsafe-eval for the backdrop, frame-ancestors 'none', form-action 'self'). /csp-report logs each distinct violation once an hour. Responses with their own policy (report frames, job HTML) keep it. No HSTS yet. Closes #222
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 00:19 UTC
- Signature
- ✔ signed by Claude Code
- Hash
d74ce22fa80e989c9d335c5a4ace5bc3f2548b941325212210e240c3968eb537- Depends on
- 19bb31ff1f5c 42fa95540468 93be9ce4653e 9a777fec4ed9 9a8e3b4419e0 aa0bfbd11f02 b64e2537d702 c8df33a6829f d23464c0ac60 df71c7892d27
- Depended on by
- 0468394734e9 cb7b3cd45dc0 2cbf6f348595 3dd6582de060
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 9 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/src/headers.rs +166 −0
| 1 | + | //! Security headers on every response (oxygenhub#222), and the endpoint |
|
| 2 | + | //! the browser reports Content-Security-Policy violations to. |
|
| 3 | + | //! |
|
| 4 | + | //! The policy is sent as `Content-Security-Policy-Report-Only` while the |
|
| 5 | + | //! pages still carry inline scripts and handlers: browsers report what the |
|
| 6 | + | //! policy would block, the hub logs it, and nothing breaks. Switching to |
|
| 7 | + | //! enforcing is one constant ([`ENFORCE`]) once the reports are quiet. |
|
| 8 | + | //! Responses that set their own policy (report frames, #127; a job's |
|
| 9 | + | //! HTML artifact) keep theirs, and may be framed as they say. |
|
| 10 | + | //! Strict-Transport-Security is deliberately not sent yet: browsers keep |
|
| 11 | + | //! it for months, so it waits for the site owner's go. |
|
| 12 | + | ||
| 13 | + | use axum::http::{header, HeaderName, HeaderValue}; |
|
| 14 | + | use axum::response::Response; |
|
| 15 | + | use std::collections::HashMap; |
|
| 16 | + | use std::sync::Mutex; |
|
| 17 | + | ||
| 18 | + | /// Where violation reports go. |
|
| 19 | + | pub const REPORT_PATH: &str = "/csp-report"; |
|
| 20 | + | ||
| 21 | + | /// Enforce the policy rather than only report it. |
|
| 22 | + | const ENFORCE: bool = false; |
|
| 23 | + | ||
| 24 | + | /// What the pages may load: their own scripts (and WebAssembly, for the |
|
| 25 | + | /// backdrop), styles (inline too: style attributes are everywhere), images |
|
| 26 | + | /// from anywhere over https (READMEs show badges), and forms posted only to |
|
| 27 | + | /// the hub (sign-in providers are reached by links, not forms). |
|
| 28 | + | const POLICY: &str = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; \ |
|
| 29 | + | img-src 'self' data: https:; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'none'; \ |
|
| 30 | + | base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report; report-to csp"; |
|
| 31 | + | ||
| 32 | + | const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), \ |
|
| 33 | + | midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), browsing-topics=(), interest-cohort=()"; |
|
| 34 | + | ||
| 35 | + | /// Adds the headers a response does not already carry. |
|
| 36 | + | pub async fn security_headers(req: axum::extract::Request, next: axum::middleware::Next) -> Response { |
|
| 37 | + | let mut res = next.run(req).await; |
|
| 38 | + | let h = res.headers_mut(); |
|
| 39 | + | let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY); |
|
| 40 | + | let mut set = |name: HeaderName, value: &'static str| { |
|
| 41 | + | h.entry(name).or_insert(HeaderValue::from_static(value)); |
|
| 42 | + | }; |
|
| 43 | + | set(header::X_CONTENT_TYPE_OPTIONS, "nosniff"); |
|
| 44 | + | set(header::REFERRER_POLICY, "strict-origin-when-cross-origin"); |
|
| 45 | + | set(HeaderName::from_static("permissions-policy"), PERMISSIONS); |
|
| 46 | + | if !own_policy { |
|
| 47 | + | set(header::X_FRAME_OPTIONS, "DENY"); |
|
| 48 | + | set(HeaderName::from_static("reporting-endpoints"), "csp=\"/csp-report\""); |
|
| 49 | + | let name = if ENFORCE { header::CONTENT_SECURITY_POLICY } else { header::CONTENT_SECURITY_POLICY_REPORT_ONLY }; |
|
| 50 | + | set(name, POLICY); |
|
| 51 | + | } |
|
| 52 | + | res |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /// One violation, as either report format gives it. |
|
| 56 | + | #[derive(Debug, PartialEq)] |
|
| 57 | + | pub struct Violation { |
|
| 58 | + | pub page: String, |
|
| 59 | + | pub directive: String, |
|
| 60 | + | pub blocked: String, |
|
| 61 | + | pub source: String, |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// The violations in a report body: the older `application/csp-report` |
|
| 65 | + | /// (`{"csp-report": {...}}`) or the Reporting API's list. |
|
| 66 | + | pub fn violations(body: &[u8]) -> Vec<Violation> { |
|
| 67 | + | let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else { return vec![] }; |
|
| 68 | + | let s = |v: &serde_json::Value, keys: &[&str]| { |
|
| 69 | + | 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())))) |
|
| 70 | + | }; |
|
| 71 | + | let one = |r: &serde_json::Value, page: &[&str], dir: &[&str], blocked: &[&str], file: &[&str], line: &[&str]| Violation { |
|
| 72 | + | page: s(r, page).unwrap_or_default(), |
|
| 73 | + | directive: s(r, dir).unwrap_or_default(), |
|
| 74 | + | blocked: s(r, blocked).unwrap_or_default(), |
|
| 75 | + | source: match (s(r, file), s(r, line)) { |
|
| 76 | + | (Some(f), Some(l)) => format!("{f}:{l}"), |
|
| 77 | + | (Some(f), None) => f, |
|
| 78 | + | _ => String::new(), |
|
| 79 | + | }, |
|
| 80 | + | }; |
|
| 81 | + | if let Some(r) = v.get("csp-report") { |
|
| 82 | + | return vec![one( |
|
| 83 | + | r, |
|
| 84 | + | &["document-uri"], |
|
| 85 | + | &["effective-directive", "violated-directive"], |
|
| 86 | + | &["blocked-uri"], |
|
| 87 | + | &["source-file"], |
|
| 88 | + | &["line-number"], |
|
| 89 | + | )]; |
|
| 90 | + | } |
|
| 91 | + | v.as_array() |
|
| 92 | + | .map(|list| { |
|
| 93 | + | list.iter() |
|
| 94 | + | .filter(|r| r.get("type").and_then(|t| t.as_str()) == Some("csp-violation")) |
|
| 95 | + | .filter_map(|r| r.get("body")) |
|
| 96 | + | .map(|b| one(b, &["documentURL"], &["effectiveDirective"], &["blockedURL"], &["sourceFile"], &["lineNumber"])) |
|
| 97 | + | .collect() |
|
| 98 | + | }) |
|
| 99 | + | .unwrap_or_default() |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | /// Each distinct violation is logged once an hour, so a busy page does not |
|
| 103 | + | /// flood the log. |
|
| 104 | + | static SEEN: Mutex<Option<HashMap<String, i64>>> = Mutex::new(None); |
|
| 105 | + | const LOG_EVERY_MS: i64 = 60 * 60_000; |
|
| 106 | + | ||
| 107 | + | fn first_in_a_while(key: String, now_ms: i64) -> bool { |
|
| 108 | + | let mut seen = SEEN.lock().unwrap(); |
|
| 109 | + | let seen = seen.get_or_insert_with(HashMap::new); |
|
| 110 | + | if seen.len() > 1000 { |
|
| 111 | + | seen.retain(|_, t| now_ms - *t < LOG_EVERY_MS); |
|
| 112 | + | } |
|
| 113 | + | match seen.get(&key) { |
|
| 114 | + | Some(t) if now_ms - t < LOG_EVERY_MS => false, |
|
| 115 | + | _ => { |
|
| 116 | + | seen.insert(key, now_ms); |
|
| 117 | + | true |
|
| 118 | + | } |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | /// `POST /csp-report`: log what the policy would block. |
|
| 123 | + | pub async fn csp_report(body: axum::body::Bytes) -> axum::http::StatusCode { |
|
| 124 | + | let clip = |s: &str| s.chars().filter(|c| !c.is_control()).take(200).collect::<String>(); |
|
| 125 | + | for v in violations(&body) { |
|
| 126 | + | // The page's path only: a query can hold anything. |
|
| 127 | + | let page = v.page.split(['?', '#']).next().unwrap_or("").to_string(); |
|
| 128 | + | let key = format!("{}|{}|{}", v.directive, v.blocked, v.source); |
|
| 129 | + | if first_in_a_while(key, crate::db::now_ms()) { |
|
| 130 | + | tracing::warn!("csp would block: {} {} on {} ({})", clip(&v.directive), clip(&v.blocked), clip(&page), clip(&v.source)); |
|
| 131 | + | } |
|
| 132 | + | } |
|
| 133 | + | axum::http::StatusCode::NO_CONTENT |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | #[cfg(test)] |
|
| 137 | + | mod tests { |
|
| 138 | + | use super::*; |
|
| 139 | + | ||
| 140 | + | #[test] |
|
| 141 | + | fn both_report_formats_are_read() { |
|
| 142 | + | 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}}"#; |
|
| 143 | + | assert_eq!( |
|
| 144 | + | violations(old), |
|
| 145 | + | vec![Violation { |
|
| 146 | + | page: "https://hub.example/a?b".into(), |
|
| 147 | + | directive: "script-src-elem".into(), |
|
| 148 | + | blocked: "inline".into(), |
|
| 149 | + | source: "https://hub.example/a:12".into() |
|
| 150 | + | }] |
|
| 151 | + | ); |
|
| 152 | + | 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":{}}]"#; |
|
| 153 | + | let v = violations(new); |
|
| 154 | + | assert_eq!(v.len(), 1); |
|
| 155 | + | assert_eq!((v[0].directive.as_str(), v[0].blocked.as_str(), v[0].source.as_str()), ("img-src", "http://cdn.example/i.png", "")); |
|
| 156 | + | assert!(violations(b"not json").is_empty()); |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | #[test] |
|
| 160 | + | fn a_violation_is_logged_once_an_hour() { |
|
| 161 | + | let k = || "script-src|inline|test-only".to_string(); |
|
| 162 | + | assert!(first_in_a_while(k(), 1_000)); |
|
| 163 | + | assert!(!first_in_a_while(k(), 2_000)); |
|
| 164 | + | assert!(first_in_a_while(k(), 1_000 + LOG_EVERY_MS)); |
|
| 165 | + | } |
|
| 166 | + | } |
modified crates/hub/src/lib.rs +2 −0
| … | 13 unchanged lines |
||
| 14 | 14 | pub mod diffusion; |
|
| 15 | 15 | pub mod events; |
|
| 16 | 16 | pub mod flow; |
|
| 17 | + | pub mod headers; |
|
| 17 | 18 | pub mod hooks; |
|
| 18 | 19 | pub mod images; |
|
| 19 | 20 | pub mod junit; |
|
| … | 149 unchanged lines |
||
| 169 | 170 | .layer(axum::middleware::from_fn(private_pages)) |
|
| 170 | 171 | .layer(axum::middleware::from_fn(slow_requests)) |
|
| 171 | 172 | .layer(axum::middleware::from_fn_with_state(https, https_only)) |
|
| 173 | + | .layer(axum::middleware::from_fn(headers::security_headers)) |
|
| 172 | 174 | } |
|
| 173 | 175 | ||
| 174 | 176 | /// The site's https address, when it has one: what [`https_only`] redirects to. |
|
| … | 132 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 85 unchanged lines |
||
| 86 | 86 | .route("/static/{*path}", get(statics::asset)) |
|
| 87 | 87 | .route("/login", get(session::login_form).post(session::login)) |
|
| 88 | 88 | .route("/device", get(device::form).post(device::answer)) |
|
| 89 | + | .route(crate::headers::REPORT_PATH, post(crate::headers::csp_report).layer(DefaultBodyLimit::max(16 * 1024))) |
|
| 89 | 90 | .route("/logout", post(session::logout)) |
|
| 90 | 91 | .route("/register", get(session::register_form).post(session::register)) |
|
| 91 | 92 | .route("/settings", get(settings::profile).post(settings::update_profile)) |
|
| … | 689 unchanged lines |
modified crates/hub/tests/accounts.rs +34 −0
| … | 1291 unchanged lines |
||
| 1292 | 1292 | let (status, _) = from("198.51.100.3", right()).await; |
|
| 1293 | 1293 | assert_eq!(status, StatusCode::SEE_OTHER, "another address signs in"); |
|
| 1294 | 1294 | } |
|
| 1295 | + | ||
| 1296 | + | #[tokio::test] |
|
| 1297 | + | async fn every_page_carries_the_security_headers_and_violations_are_reported() { |
|
| 1298 | + | let h = hub().await; |
|
| 1299 | + | let res = oxygenhub::router(h.app.clone()).oneshot(Request::get("/login").body(Body::empty()).unwrap()).await.unwrap(); |
|
| 1300 | + | let hd = res.headers(); |
|
| 1301 | + | assert_eq!(hd[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); |
|
| 1302 | + | assert_eq!(hd[header::REFERRER_POLICY], "strict-origin-when-cross-origin"); |
|
| 1303 | + | assert_eq!(hd[header::X_FRAME_OPTIONS], "DENY"); |
|
| 1304 | + | assert!(hd["permissions-policy"].to_str().unwrap().contains("camera=()")); |
|
| 1305 | + | // Reported, not yet enforced; and no HSTS until the site owner says. |
|
| 1306 | + | let csp = hd[header::CONTENT_SECURITY_POLICY_REPORT_ONLY].to_str().unwrap(); |
|
| 1307 | + | for part in [ |
|
| 1308 | + | "default-src 'self'", |
|
| 1309 | + | "object-src 'none'", |
|
| 1310 | + | "frame-ancestors 'none'", |
|
| 1311 | + | "form-action 'self'", |
|
| 1312 | + | "'wasm-unsafe-eval'", |
|
| 1313 | + | "report-uri /csp-report", |
|
| 1314 | + | ] { |
|
| 1315 | + | assert!(csp.contains(part), "{part} in {csp}"); |
|
| 1316 | + | } |
|
| 1317 | + | assert!(hd.get(header::CONTENT_SECURITY_POLICY).is_none() && hd.get(header::STRICT_TRANSPORT_SECURITY).is_none()); |
|
| 1318 | + | // Static files and errors carry them too. |
|
| 1319 | + | let res = oxygenhub::router(h.app.clone()).oneshot(Request::get("/no/such/page").body(Body::empty()).unwrap()).await.unwrap(); |
|
| 1320 | + | assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); |
|
| 1321 | + | ||
| 1322 | + | let report = |
|
| 1323 | + | r#"{"csp-report":{"document-uri":"http://127.0.0.1/login","violated-directive":"script-src-elem","blocked-uri":"inline"}}"#; |
|
| 1324 | + | let req = Request::post("/csp-report").header(header::CONTENT_TYPE, "application/csp-report").body(Body::from(report)).unwrap(); |
|
| 1325 | + | assert_eq!(oxygenhub::router(h.app.clone()).oneshot(req).await.unwrap().status(), StatusCode::NO_CONTENT); |
|
| 1326 | + | let big = Request::post("/csp-report").body(Body::from(vec![b' '; 64 * 1024])).unwrap(); |
|
| 1327 | + | assert_eq!(oxygenhub::router(h.app.clone()).oneshot(big).await.unwrap().status(), StatusCode::PAYLOAD_TOO_LARGE); |
|
| 1328 | + | } |
modified crates/hub/tests/flow.rs +5 −0
| … | 1058 unchanged lines |
||
| 1059 | 1059 | assert_eq!(res.status(), StatusCode::OK); |
|
| 1060 | 1060 | let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string(); |
|
| 1061 | 1061 | assert!(csp.starts_with("sandbox allow-scripts;") && csp.contains("default-src 'none'"), "{csp}"); |
|
| 1062 | + | // The site's own policy and frame refusal stay off it: report frames are framed (#222). |
|
| 1063 | + | assert!( |
|
| 1064 | + | res.headers().get(header::X_FRAME_OPTIONS).is_none() && res.headers().get(header::CONTENT_SECURITY_POLICY_REPORT_ONLY).is_none() |
|
| 1065 | + | ); |
|
| 1066 | + | assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); |
|
| 1062 | 1067 | assert_eq!(res.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); |
|
| 1063 | 1068 | // The page as kept, with the frame script that sizes the frame added. |
|
| 1064 | 1069 | let served = res.into_body().collect().await.unwrap().to_bytes().to_vec(); |
|
| … | 3020 unchanged lines |
modified docs/deploy.md +19 −3
| … | 45 unchanged lines |
||
| 46 | 46 | cookie it sets is `Secure`. Requests made to the hub directly, such as a |
|
| 47 | 47 | runner on the same host using `http://127.0.0.1:7300`, carry no such |
|
| 48 | 48 | header and are served as before. In front, Cloudflare's Always Use HTTPS |
|
| 49 | - | is on (2026-10-15). Strict-Transport-Security is not sent yet: it waits |
|
| 50 | - | for the security headers (#222) and the site owner's decision, because |
|
| 51 | - | browsers remember it for months. |
|
| 49 | + | is on (2026-10-15). Strict-Transport-Security is not sent yet: the security headers (#222) |
|
| 50 | + | are in, and it waits for the site owner's decision, because browsers |
|
| 51 | + | remember it for months. |
|
| 52 | 52 | ||
| 53 | + | ## Security headers (oxygenhub#222) |
|
| 54 | + | ||
| 55 | + | Every response carries `X-Content-Type-Options: nosniff`, |
|
| 56 | + | `Referrer-Policy: strict-origin-when-cross-origin` and a |
|
| 57 | + | `Permissions-Policy` that turns off the camera, microphone, location and |
|
| 58 | + | the like; pages also carry `X-Frame-Options: DENY` and a Content Security |
|
| 59 | + | Policy (`frame-ancestors 'none'`, scripts and forms from the hub only, |
|
| 60 | + | WebAssembly for the backdrop). The policy is **report-only** for now: |
|
| 61 | + | browsers report what it would block to `/csp-report`, and the hub logs |
|
| 62 | + | each distinct violation once an hour (`csp would block: …`). The pages |
|
| 63 | + | still use inline scripts and handlers, which it reports; once they have |
|
| 64 | + | moved into `hub.js` and the log is quiet, `ENFORCE` in |
|
| 65 | + | `crates/hub/src/headers.rs` switches it on. Responses with their own |
|
| 66 | + | policy (report frames, a job's HTML artifact) keep it. HSTS is not sent: |
|
| 67 | + | it waits for the site owner's decision. |
|
| 68 | + | ||
| 53 | 69 | ## Guessing is slowed (oxygenhub#221) |
|
| 54 | 70 | ||
| 55 | 71 | Password sign-in, the two-factor code step, device codes and password |
|
| … | 29 unchanged lines |