OXYGENHUB
# 4e2cad00a484 oxygenhub / crates / hub / src / headers.rs
7.1 KiB · Rust · last changed Security headers on every response; CSP report-only with a report endpoint 20 days ago source blame history raw
d74ce22f Andy McCall ↶ 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
}