OXYGENHUB

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