OXYGENHUB
# 8dcaeeb848c6 oxygenhub / crates / hub / src / web / security_txt.rs
5.5 KiB · Rust · last changed security.txt from a contact set under Administration 20 days ago source blame history raw
1
//! `security.txt` (RFC 9116, oxygenhub#224): where to report a
2
//! vulnerability in this hub. A site administrator sets the contact and,
3
//! if there is one, a policy page under Administration; until a contact is
4
//! set there is no file (404). `Expires` is always just under a year ahead,
5
//! as the RFC asks, so the file never goes stale.
6
7
use super::settings::csrf;
8
use super::{Error, Page, Shell};
9
use crate::auth::Viewer;
10
use crate::db::{self, accounts};
11
use crate::Shared;
12
use axum::extract::{Form, State};
13
use axum::http::{header, StatusCode};
14
use axum::response::{IntoResponse, Response};
15
use serde::Deserialize;
16
17
const CONTACT: &str = "security_contact";
18
const POLICY: &str = "security_policy";
19
/// How far ahead `Expires` is: under a year (RFC 9116 §2.5.5).
20
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000;
21
22
/// The saved contact and policy (empty when unset).
23
pub async fn settings(app: &Shared) -> (String, String) {
24
    let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() };
25
    (get(CONTACT).await, get(POLICY).await)
26
}
27
28
/// A contact as the file gives it: an email address becomes a `mailto:`
29
/// URI; an https or mailto URI is kept. `None` for anything else.
30
pub fn contact_uri(contact: &str) -> Option<String> {
31
    let c = contact.trim();
32
    if c.is_empty() || c.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
33
        return None;
34
    }
35
    if c.starts_with("https://") || c.starts_with("mailto:") {
36
        return Some(c.to_string());
37
    }
38
    let (user, domain) = c.split_once('@')?;
39
    (!user.is_empty() && domain.contains('.') && !domain.contains('@') && !c.contains(':')).then(|| format!("mailto:{c}"))
40
}
41
42
/// The file's text, for a contact and policy, at `now_ms`.
43
pub fn text(base_url: &str, contact: &str, policy: &str, now_ms: i64) -> Option<String> {
44
    let contact = contact_uri(contact)?;
45
    let expires = crate::render::time::rfc3339((now_ms + EXPIRES_MS) as u64);
46
    let base = base_url.trim_end_matches('/');
47
    let mut out = format!("Contact: {contact}\nExpires: {expires}\n");
48
    if !policy.trim().is_empty() {
49
        out.push_str(&format!("Policy: {}\n", policy.trim()));
50
    }
51
    out.push_str(&format!("Preferred-Languages: en\nCanonical: {base}/.well-known/security.txt\n"));
52
    Some(out)
53
}
54
55
/// `GET /.well-known/security.txt` and `/security.txt`.
56
pub async fn serve(State(app): State<Shared>) -> Response {
57
    let (contact, policy) = settings(&app).await;
58
    match text(app.config.base_url(), &contact, &policy, db::now_ms()) {
59
        Some(t) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], t).into_response(),
60
        None => (StatusCode::NOT_FOUND, "This hub has no security contact set.\n").into_response(),
61
    }
62
}
63
64
#[derive(Deserialize)]
65
pub struct SecurityForm {
66
    #[serde(rename = "_csrf")]
67
    csrf_token: String,
68
    #[serde(default)]
69
    contact: String,
70
    #[serde(default)]
71
    policy: String,
72
}
73
74
/// `POST /admin/security`: the contact (an email address or https page)
75
/// and an optional https policy page; an empty contact takes the file down.
76
pub async fn save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SecurityForm>) -> Result<Page<super::admin::AdminPage>, Error> {
77
    let shell = Shell::new(&app, "/admin", &viewer);
78
    let me = super::admin::require_admin(&viewer, &shell)?;
79
    csrf(&viewer, &f.csrf_token, &shell)?;
80
    let (contact, policy) = (f.contact.trim(), f.policy.trim());
81
    if !contact.is_empty() && contact_uri(contact).is_none() {
82
        return Err(Error::bad(shell, "The contact is an email address or an https:// page."));
83
    }
84
    if !policy.is_empty() && (!policy.starts_with("https://") || policy.chars().any(char::is_whitespace)) {
85
        return Err(Error::bad(shell, "The policy is an https:// page, or empty."));
86
    }
87
    let e = |e| Error::from_db(shell.clone(), e);
88
    accounts::set_setting(&app.db, CONTACT, contact).await.map_err(e)?;
89
    accounts::set_setting(&app.db, POLICY, policy).await.map_err(e)?;
90
    accounts::audit(&app.db, Some(&me), "admin.security_contact", contact, policy).await.ok();
91
    let notice = if contact.is_empty() {
92
        "No security contact: /.well-known/security.txt is gone.".to_string()
93
    } else {
94
        "Security contact saved: /.well-known/security.txt names it.".to_string()
95
    };
96
    super::admin::admin_page(&app, shell, Some(notice)).await
97
}
98
99
#[cfg(test)]
100
mod tests {
101
    use super::*;
102
103
    #[test]
104
    fn contacts_become_uris_or_are_refused() {
105
        assert_eq!(contact_uri("[email protected]").as_deref(), Some("mailto:[email protected]"));
106
        assert_eq!(contact_uri(" https://example.org/report ").as_deref(), Some("https://example.org/report"));
107
        assert_eq!(contact_uri("mailto:[email protected]").as_deref(), Some("mailto:[email protected]"));
108
        for bad in ["", "http://example.org", "nobody", "a@b", "a [email protected]", "javascript:alert(1)", "[email protected]\nContact: evil"] {
109
            assert_eq!(contact_uri(bad), None, "{bad:?}");
110
        }
111
    }
112
113
    #[test]
114
    fn the_file_expires_under_a_year_ahead_and_is_canonical() {
115
        let now = 1_800_000_000_000; // 2027-01-15
116
        let t = text("https://hub.example/", "[email protected]", "https://hub.example/policy", now).unwrap();
117
        assert_eq!(
118
            t,
119
            "Contact: mailto:[email protected]\nExpires: 2028-01-10T08:00:00Z\nPolicy: https://hub.example/policy\n\
120
             Preferred-Languages: en\nCanonical: https://hub.example/.well-known/security.txt\n"
121
        );
122
        assert!(!text("https://hub.example", "[email protected]", "", now).unwrap().contains("Policy"));
123
        assert!(text("https://hub.example", "", "", now).is_none());
124
    }
125
}