OXYGENHUB
# 56b8dbe66974 oxygenhub / crates / hub / src / headers.rs
7.7 KiB · Rust · last changed A security contact page; HSTS from [server] hsts_max_age 20 days ago source blame history raw
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.
37
pub fn hsts(base_url: &str, max_age: u64) -> Option<HeaderValue> {
38
    (base_url.starts_with("https://") && max_age > 0).then(|| HeaderValue::from_str(&format!("max-age={max_age}")).ok()).flatten()
39
}
40
41
/// Adds the headers a response does not already carry.
42
pub async fn security_headers(
43
    axum::extract::State(hsts): axum::extract::State<Option<HeaderValue>>,
44
    req: axum::extract::Request,
45
    next: axum::middleware::Next,
46
) -> Response {
47
    let mut res = next.run(req).await;
48
    let h = res.headers_mut();
49
    if let Some(v) = hsts {
50
        h.entry(header::STRICT_TRANSPORT_SECURITY).or_insert(v);
51
    }
52
    let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY);
53
    let mut set = |name: HeaderName, value: &'static str| {
54
        h.entry(name).or_insert(HeaderValue::from_static(value));
55
    };
56
    set(header::X_CONTENT_TYPE_OPTIONS, "nosniff");
57
    set(header::REFERRER_POLICY, "strict-origin-when-cross-origin");
58
    set(HeaderName::from_static("permissions-policy"), PERMISSIONS);
59
    if !own_policy {
60
        set(header::X_FRAME_OPTIONS, "DENY");
61
        set(HeaderName::from_static("reporting-endpoints"), "csp=\"/csp-report\"");
62
        let name = if ENFORCE { header::CONTENT_SECURITY_POLICY } else { header::CONTENT_SECURITY_POLICY_REPORT_ONLY };
63
        set(name, POLICY);
64
    }
65
    res
66
}
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.
79
pub fn violations(body: &[u8]) -> Vec<Violation> {
80
    let Ok(v) = serde_json::from_slice::<serde_json::Value>(body) else { return vec![] };
81
    let s = |v: &serde_json::Value, keys: &[&str]| {
82
        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()))))
83
    };
84
    let one = |r: &serde_json::Value, page: &[&str], dir: &[&str], blocked: &[&str], file: &[&str], line: &[&str]| Violation {
85
        page: s(r, page).unwrap_or_default(),
86
        directive: s(r, dir).unwrap_or_default(),
87
        blocked: s(r, blocked).unwrap_or_default(),
88
        source: match (s(r, file), s(r, line)) {
89
            (Some(f), Some(l)) => format!("{f}:{l}"),
90
            (Some(f), None) => f,
91
            _ => String::new(),
92
        },
93
    };
94
    if let Some(r) = v.get("csp-report") {
95
        return vec![one(
96
            r,
97
            &["document-uri"],
98
            &["effective-directive", "violated-directive"],
99
            &["blocked-uri"],
100
            &["source-file"],
101
            &["line-number"],
102
        )];
103
    }
104
    v.as_array()
105
        .map(|list| {
106
            list.iter()
107
                .filter(|r| r.get("type").and_then(|t| t.as_str()) == Some("csp-violation"))
108
                .filter_map(|r| r.get("body"))
109
                .map(|b| one(b, &["documentURL"], &["effectiveDirective"], &["blockedURL"], &["sourceFile"], &["lineNumber"]))
110
                .collect()
111
        })
112
        .unwrap_or_default()
113
}
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
120
fn first_in_a_while(key: String, now_ms: i64) -> bool {
121
    let mut seen = SEEN.lock().unwrap();
122
    let seen = seen.get_or_insert_with(HashMap::new);
123
    if seen.len() > 1000 {
124
        seen.retain(|_, t| now_ms - *t < LOG_EVERY_MS);
125
    }
126
    match seen.get(&key) {
127
        Some(t) if now_ms - t < LOG_EVERY_MS => false,
128
        _ => {
129
            seen.insert(key, now_ms);
130
            true
131
        }
132
    }
133
}
134
135
/// `POST /csp-report`: log what the policy would block.
136
pub async fn csp_report(body: axum::body::Bytes) -> axum::http::StatusCode {
137
    let clip = |s: &str| s.chars().filter(|c| !c.is_control()).take(200).collect::<String>();
138
    for v in violations(&body) {
139
        // The page's path only: a query can hold anything.
140
        let page = v.page.split(['?', '#']).next().unwrap_or("").to_string();
141
        let key = format!("{}|{}|{}", v.directive, v.blocked, v.source);
142
        if first_in_a_while(key, crate::db::now_ms()) {
143
            tracing::warn!("csp would block: {} {} on {} ({})", clip(&v.directive), clip(&v.blocked), clip(&page), clip(&v.source));
144
        }
145
    }
146
    axum::http::StatusCode::NO_CONTENT
147
}
148
149
#[cfg(test)]
150
mod tests {
151
    use super::*;
152
153
    #[test]
154
    fn both_report_formats_are_read() {
155
        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}}"#;
156
        assert_eq!(
157
            violations(old),
158
            vec![Violation {
159
                page: "https://hub.example/a?b".into(),
160
                directive: "script-src-elem".into(),
161
                blocked: "inline".into(),
162
                source: "https://hub.example/a:12".into()
163
            }]
164
        );
165
        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":{}}]"#;
166
        let v = violations(new);
167
        assert_eq!(v.len(), 1);
168
        assert_eq!((v[0].directive.as_str(), v[0].blocked.as_str(), v[0].source.as_str()), ("img-src", "http://cdn.example/i.png", ""));
169
        assert!(violations(b"not json").is_empty());
170
    }
171
172
    #[test]
173
    fn a_violation_is_logged_once_an_hour() {
174
        let k = || "script-src|inline|test-only".to_string();
175
        assert!(first_in_a_while(k(), 1_000));
176
        assert!(!first_in_a_while(k(), 2_000));
177
        assert!(first_in_a_while(k(), 1_000 + LOG_EVERY_MS));
178
    }
179
}