OXYGENHUB

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

116 of 123 lines run, 29 of 49 functions · run #480 on api-unknown-fields · 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 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 sent only when `[server] hsts_max_age` is
11
//! set (oxygenhub#231), raised in stages: browsers keep it.
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
/// The Strict-Transport-Security value for a site, if it sends one
36
/// (oxygenhub#231): only for an https address and a max-age above 0.
372671×
pub fn hsts(base_url: &str, max_age: u64) -> Option<HeaderValue> {
382671×
    (base_url.starts_with("https://") && max_age > 0).then(|| HeaderValue::from_str(&format!("max-age={max_age}")).ok()).flatten()
392671×
}
40
41
/// Adds the headers a response does not already carry.
423659×
pub async fn security_headers(
433659×
    axum::extract::State(hsts): axum::extract::State<Option<HeaderValue>>,
443659×
    req: axum::extract::Request,
453659×
    next: axum::middleware::Next,
463659×
) -> Response {
473659×
    let mut res = next.run(req).await;
483659×
    let h = res.headers_mut();
493659×
    if let Some(v) = hsts {
500×
        h.entry(header::STRICT_TRANSPORT_SECURITY).or_insert(v);
513659×
    }
523659×
    let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY);
5321933×
    let mut set = |name: HeaderName, value: &'static str| {
5421933×
        h.entry(name).or_insert(HeaderValue::from_static(value));
5521933×
    };
563659×
    set(header::X_CONTENT_TYPE_OPTIONS, "nosniff");
573659×
    set(header::REFERRER_POLICY, "strict-origin-when-cross-origin");
583659×
    set(HeaderName::from_static("permissions-policy"), PERMISSIONS);
593659×
    if !own_policy {
603652×
        set(header::X_FRAME_OPTIONS, "DENY");
613652×
        set(HeaderName::from_static("reporting-endpoints"), "csp=\"/csp-report\"");
623652×
        let name = if ENFORCE { header::CONTENT_SECURITY_POLICY } else { header::CONTENT_SECURITY_POLICY_REPORT_ONLY };
633652×
        set(name, POLICY);
647×
    }
653659×
    res
663659×
}
67
68
/// One violation, as either report format gives it.
69
#[derive(Debug, PartialEq)]
70
pub struct Violation {
71
    pub page: String,
72
    pub directive: String,
73
    pub blocked: String,
74
    pub source: String,
75
}
76
77
/// The violations in a report body: the older `application/csp-report`
78
/// (`{"csp-report": {...}}`) or the Reporting API's list.
794×
pub fn violations(body: &[u8]) -> Vec<Violation> {
804×
    let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else { return vec![] };
8115×
    let s = |v: &serde_json::Value, keys: &[&str]| {
8217×
        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()))))
8315×
    };
843×
    let one = |r: &serde_json::Value, page: &[&str], dir: &[&str], blocked: &[&str], file: &[&str], line: &[&str]| Violation {
853×
        page: s(r, page).unwrap_or_default(),
863×
        directive: s(r, dir).unwrap_or_default(),
873×
        blocked: s(r, blocked).unwrap_or_default(),
883×
        source: match (s(r, file), s(r, line)) {
891×
            (Some(f), Some(l)) => format!("{f}:{l}"),
900×
            (Some(f), None) => f,
912×
            _ => String::new(),
92
        },
933×
    };
943×
    if let Some(r) = v.get("csp-report") {
952×
        return vec![one(
962×
            r,
972×
            &["document-uri"],
982×
            &["effective-directive", "violated-directive"],
992×
            &["blocked-uri"],
1002×
            &["source-file"],
1012×
            &["line-number"],
1022×
        )];
1031×
    }
1041×
    v.as_array()
1051×
        .map(|list| {
1061×
            list.iter()
1072×
                .filter(|r| r.get("type").and_then(|t| t.as_str()) == Some("csp-violation"))
1081×
                .filter_map(|r| r.get("body"))
1091×
                .map(|b| one(b, &["documentURL"], &["effectiveDirective"], &["blockedURL"], &["sourceFile"], &["lineNumber"]))
1101×
                .collect()
1111×
        })
1121×
        .unwrap_or_default()
1134×
}
114
115
/// Each distinct violation is logged once an hour, so a busy page does not
116
/// flood the log.
117
static SEEN: Mutex<Option<HashMap<String, i64>>> = Mutex::new(None);
118
const LOG_EVERY_MS: i64 = 60 * 60_000;
119
1204×
fn first_in_a_while(key: String, now_ms: i64) -> bool {
1214×
    let mut seen = SEEN.lock().unwrap();
1224×
    let seen = seen.get_or_insert_with(HashMap::new);
1234×
    if seen.len() > 1000 {
1240×
        seen.retain(|_, t| now_ms - *t < LOG_EVERY_MS);
1254×
    }
1264×
    match seen.get(&key) {
1272×
        Some(t) if now_ms - t < LOG_EVERY_MS => false,
128
        _ => {
1293×
            seen.insert(key, now_ms);
1303×
            true
131
        }
132
    }
1334×
}
134
135
/// `POST /csp-report`: log what the policy would block.
1361×
pub async fn csp_report(body: axum::body::Bytes) -> axum::http::StatusCode {
1371×
    let clip = |s: &str| s.chars().filter(|c| !c.is_control()).take(200).collect::<String>();
1381×
    for v in violations(&body) {
139
        // The page's path only: a query can hold anything.
1401×
        let page = v.page.split(['?', '#']).next().unwrap_or("").to_string();
1411×
        let key = format!("{}|{}|{}", v.directive, v.blocked, v.source);
1421×
        if first_in_a_while(key, crate::db::now_ms()) {
1431×
            tracing::warn!("csp would block: {} {} on {} ({})", clip(&v.directive), clip(&v.blocked), clip(&page), clip(&v.source));
1440×
        }
145
    }
1461×
    axum::http::StatusCode::NO_CONTENT
1471×
}
148
149
#[cfg(test)]
150
mod tests {
151
    use super::*;
152
153
    #[test]
1541×
    fn both_report_formats_are_read() {
1551×
        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}}"#;
1561×
        assert_eq!(
1571×
            violations(old),
1581×
            vec![Violation {
1591×
                page: "https://hub.example/a?b".into(),
1601×
                directive: "script-src-elem".into(),
1611×
                blocked: "inline".into(),
1621×
                source: "https://hub.example/a:12".into()
1631×
            }]
164
        );
1651×
        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":{}}]"#;
1661×
        let v = violations(new);
1671×
        assert_eq!(v.len(), 1);
1681×
        assert_eq!((v[0].directive.as_str(), v[0].blocked.as_str(), v[0].source.as_str()), ("img-src", "http://cdn.example/i.png", ""));
1691×
        assert!(violations(b"not json").is_empty());
1701×
    }
171
172
    #[test]
1731×
    fn a_violation_is_logged_once_an_hour() {
1743×
        let k = || "script-src|inline|test-only".to_string();
1751×
        assert!(first_in_a_while(k(), 1_000));
1761×
        assert!(!first_in_a_while(k(), 2_000));
1771×
        assert!(first_in_a_while(k(), 1_000 + LOG_EVERY_MS));
1781×
    }
179
}