OXYGENHUB

Coverage / crates/hub/src/headers.rs 94.3%

116 of 123 lines run, 29 of 49 functions · run #536 on main · run missed

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 enforced (#227): the pages carry no inline scripts or
5
//! handlers, and in the week of reports before it was switched on the only
6
//! thing it would block was Cloudflare's analytics beacon, which the
7
//! policy now allows. Violations are still reported and logged; going back
8
//! to report-only is one constant ([`ENFORCE`]).
9
//! Responses that set their own policy (report frames, #127; a job's
10
//! HTML artifact) keep theirs, and may be framed as they say.
11
//! Strict-Transport-Security is sent only when `[server] hsts_max_age` is
12
//! set (oxygenhub#231), raised in stages: browsers keep it.
13
14
use axum::http::{header, HeaderName, HeaderValue};
15
use axum::response::Response;
16
use std::collections::HashMap;
17
use std::sync::Mutex;
18
19
/// Where violation reports go.
20
pub const REPORT_PATH: &str = "/csp-report";
21
22
/// Enforce the policy rather than only report it.
23
const ENFORCE: bool = true;
24
25
/// What the pages may load: their own scripts (and WebAssembly, for the
26
/// backdrop), styles (inline too: style attributes are everywhere), images
27
/// from anywhere over https (READMEs show badges), and forms posted only to
28
/// the hub (sign-in providers are reached by links, not forms). Cloudflare,
29
/// in front of oxygenhub.io, adds its Web Analytics beacon to pages: its
30
/// script and where it reports are allowed, nothing else of theirs.
31
const POLICY: &str = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://static.cloudflareinsights.com; \
32
style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; \
33
connect-src 'self' https://cloudflareinsights.com; media-src 'self'; object-src 'none'; \
34
base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-uri /csp-report; report-to csp";
35
36
const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), \
37
midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), browsing-topics=(), interest-cohort=()";
38
39
/// The Strict-Transport-Security value for a site, if it sends one
40
/// (oxygenhub#231): only for an https address and a max-age above 0.
412906×
pub fn hsts(base_url: &str, max_age: u64) -> Option<HeaderValue> {
422906×
    (base_url.starts_with("https://") && max_age > 0).then(|| HeaderValue::from_str(&format!("max-age={max_age}")).ok()).flatten()
432906×
}
44
45
/// Adds the headers a response does not already carry.
463931×
pub async fn security_headers(
473931×
    axum::extract::State(hsts): axum::extract::State<Option<HeaderValue>>,
483931×
    req: axum::extract::Request,
493931×
    next: axum::middleware::Next,
503931×
) -> Response {
513931×
    let mut res = next.run(req).await;
523931×
    let h = res.headers_mut();
533931×
    if let Some(v) = hsts {
540×
        h.entry(header::STRICT_TRANSPORT_SECURITY).or_insert(v);
553931×
    }
563931×
    let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY);
5723550×
    let mut set = |name: HeaderName, value: &'static str| {
5823550×
        h.entry(name).or_insert(HeaderValue::from_static(value));
5923550×
    };
603931×
    set(header::X_CONTENT_TYPE_OPTIONS, "nosniff");
613931×
    set(header::REFERRER_POLICY, "strict-origin-when-cross-origin");
623931×
    set(HeaderName::from_static("permissions-policy"), PERMISSIONS);
633931×
    if !own_policy {
643919×
        set(header::X_FRAME_OPTIONS, "DENY");
653919×
        set(HeaderName::from_static("reporting-endpoints"), "csp=\"/csp-report\"");
663919×
        let name = if ENFORCE { header::CONTENT_SECURITY_POLICY } else { header::CONTENT_SECURITY_POLICY_REPORT_ONLY };
673919×
        set(name, POLICY);
6812×
    }
693931×
    res
703931×
}
71
72
/// One violation, as either report format gives it.
73
#[derive(Debug, PartialEq)]
74
pub struct Violation {
75
    pub page: String,
76
    pub directive: String,
77
    pub blocked: String,
78
    pub source: String,
79
}
80
81
/// The violations in a report body: the older `application/csp-report`
82
/// (`{"csp-report": {...}}`) or the Reporting API's list.
834×
pub fn violations(body: &[u8]) -> Vec<Violation> {
844×
    let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else { return vec![] };
8515×
    let s = |v: &serde_json::Value, keys: &[&str]| {
8617×
        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()))))
8715×
    };
883×
    let one = |r: &serde_json::Value, page: &[&str], dir: &[&str], blocked: &[&str], file: &[&str], line: &[&str]| Violation {
893×
        page: s(r, page).unwrap_or_default(),
903×
        directive: s(r, dir).unwrap_or_default(),
913×
        blocked: s(r, blocked).unwrap_or_default(),
923×
        source: match (s(r, file), s(r, line)) {
931×
            (Some(f), Some(l)) => format!("{f}:{l}"),
940×
            (Some(f), None) => f,
952×
            _ => String::new(),
96
        },
973×
    };
983×
    if let Some(r) = v.get("csp-report") {
992×
        return vec![one(
1002×
            r,
1012×
            &["document-uri"],
1022×
            &["effective-directive", "violated-directive"],
1032×
            &["blocked-uri"],
1042×
            &["source-file"],
1052×
            &["line-number"],
1062×
        )];
1071×
    }
1081×
    v.as_array()
1091×
        .map(|list| {
1101×
            list.iter()
1112×
                .filter(|r| r.get("type").and_then(|t| t.as_str()) == Some("csp-violation"))
1121×
                .filter_map(|r| r.get("body"))
1131×
                .map(|b| one(b, &["documentURL"], &["effectiveDirective"], &["blockedURL"], &["sourceFile"], &["lineNumber"]))
1141×
                .collect()
1151×
        })
1161×
        .unwrap_or_default()
1174×
}
118
119
/// Each distinct violation is logged once an hour, so a busy page does not
120
/// flood the log.
121
static SEEN: Mutex<Option<HashMap<String, i64>>> = Mutex::new(None);
122
const LOG_EVERY_MS: i64 = 60 * 60_000;
123
1244×
fn first_in_a_while(key: String, now_ms: i64) -> bool {
1254×
    let mut seen = SEEN.lock().unwrap();
1264×
    let seen = seen.get_or_insert_with(HashMap::new);
1274×
    if seen.len() > 1000 {
1280×
        seen.retain(|_, t| now_ms - *t < LOG_EVERY_MS);
1294×
    }
1304×
    match seen.get(&key) {
1312×
        Some(t) if now_ms - t < LOG_EVERY_MS => false,
132
        _ => {
1333×
            seen.insert(key, now_ms);
1343×
            true
135
        }
136
    }
1374×
}
138
139
/// `POST /csp-report`: log what the policy would block.
1401×
pub async fn csp_report(body: axum::body::Bytes) -> axum::http::StatusCode {
1411×
    let clip = |s: &str| s.chars().filter(|c| !c.is_control()).take(200).collect::<String>();
1421×
    for v in violations(&body) {
143
        // The page's path only: a query can hold anything.
1441×
        let page = v.page.split(['?', '#']).next().unwrap_or("").to_string();
1451×
        let key = format!("{}|{}|{}", v.directive, v.blocked, v.source);
1461×
        if first_in_a_while(key, crate::db::now_ms()) {
1471×
            tracing::warn!("csp would block: {} {} on {} ({})", clip(&v.directive), clip(&v.blocked), clip(&page), clip(&v.source));
1480×
        }
149
    }
1501×
    axum::http::StatusCode::NO_CONTENT
1511×
}
152
153
#[cfg(test)]
154
mod tests {
155
    use super::*;
156
157
    #[test]
1581×
    fn both_report_formats_are_read() {
1591×
        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}}"#;
1601×
        assert_eq!(
1611×
            violations(old),
1621×
            vec![Violation {
1631×
                page: "https://hub.example/a?b".into(),
1641×
                directive: "script-src-elem".into(),
1651×
                blocked: "inline".into(),
1661×
                source: "https://hub.example/a:12".into()
1671×
            }]
168
        );
1691×
        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":{}}]"#;
1701×
        let v = violations(new);
1711×
        assert_eq!(v.len(), 1);
1721×
        assert_eq!((v[0].directive.as_str(), v[0].blocked.as_str(), v[0].source.as_str()), ("img-src", "http://cdn.example/i.png", ""));
1731×
        assert!(violations(b"not json").is_empty());
1741×
    }
175
176
    #[test]
1771×
    fn a_violation_is_logged_once_an_hour() {
1783×
        let k = || "script-src|inline|test-only".to_string();
1791×
        assert!(first_in_a_while(k(), 1_000));
1801×
        assert!(!first_in_a_while(k(), 2_000));
1811×
        assert!(first_in_a_while(k(), 1_000 + LOG_EVERY_MS));
1821×
    }
183
}