OXYGENHUB
# 1c4ad601ef9d oxygenhub / crates / hub / src / web / security_txt.rs
8.1 KiB · Rust · last changed A security contact page; HSTS from [server] hsts_max_age 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
const EMAIL: &str = "security_email";
20
const PAGE: &str = "security_page";
21
/// The longest page text kept.
22
const MAX_PAGE: usize = 20_000;
23
24
/// What `/security-contact` says until an administrator writes their own
25
/// (oxygenhub#230). It makes no promises on the site's behalf.
26
pub const DEFAULT_PAGE: &str = "If you think you have found a security problem in this hub, please tell us privately rather than in a public issue, proposal or comment.\n\n\
27
Please include:\n\n\
28
- what you found, and where (the page, the API call, the repository if one is involved);\n\
29
- how to reproduce it, step by step;\n\
30
- what you think someone could do with it.\n\n\
31
While you look, please do not read or change other people's data beyond what you need to show the problem, do not run tests that slow the service down for others, and give us a fair chance to fix it before you tell anyone else.\n";
32
/// How far ahead `Expires` is: under a year (RFC 9116 §2.5.5).
33
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000;
34
35
/// The reporting address and the page text (empty when unset).
36
pub async fn page_settings(app: &Shared) -> (String, String) {
37
    let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() };
38
    (get(EMAIL).await, get(PAGE).await)
39
}
40
41
#[derive(askama::Template)]
42
#[template(path = "security_contact.html")]
43
pub struct ContactPage {
44
    shell: Shell,
45
    email: String,
46
    /// The page text as HTML, sanitised by the Markdown renderer.
47
    body_html: String,
48
}
49
50
/// `GET /security-contact`: how to report a security problem (oxygenhub#230).
51
pub async fn contact_page(State(app): State<Shared>, viewer: Viewer) -> Page<ContactPage> {
52
    let shell = Shell::new(&app, "/security-contact", &viewer);
53
    let (email, text) = page_settings(&app).await;
54
    let text = if text.trim().is_empty() { DEFAULT_PAGE.to_string() } else { text };
55
    Page(ContactPage { shell, email, body_html: crate::render::markdown::render(&text, "", "") })
56
}
57
58
/// The saved contact and policy (empty when unset).
59
pub async fn settings(app: &Shared) -> (String, String) {
60
    let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() };
61
    (get(CONTACT).await, get(POLICY).await)
62
}
63
64
/// A contact as the file gives it: an email address becomes a `mailto:`
65
/// URI; an https or mailto URI is kept. `None` for anything else.
66
pub fn contact_uri(contact: &str) -> Option<String> {
67
    let c = contact.trim();
68
    if c.is_empty() || c.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
69
        return None;
70
    }
71
    if c.starts_with("https://") || c.starts_with("mailto:") {
72
        return Some(c.to_string());
73
    }
74
    let (user, domain) = c.split_once('@')?;
75
    (!user.is_empty() && domain.contains('.') && !domain.contains('@') && !c.contains(':')).then(|| format!("mailto:{c}"))
76
}
77
78
/// The file's text, for a contact and policy, at `now_ms`.
79
pub fn text(base_url: &str, contact: &str, policy: &str, now_ms: i64) -> Option<String> {
80
    let contact = contact_uri(contact)?;
81
    let expires = crate::render::time::rfc3339((now_ms + EXPIRES_MS) as u64);
82
    let base = base_url.trim_end_matches('/');
83
    let mut out = format!("Contact: {contact}\nExpires: {expires}\n");
84
    if !policy.trim().is_empty() {
85
        out.push_str(&format!("Policy: {}\n", policy.trim()));
86
    }
87
    out.push_str(&format!("Preferred-Languages: en\nCanonical: {base}/.well-known/security.txt\n"));
88
    Some(out)
89
}
90
91
/// `GET /.well-known/security.txt` and `/security.txt`.
92
pub async fn serve(State(app): State<Shared>) -> Response {
93
    let (contact, policy) = settings(&app).await;
94
    match text(app.config.base_url(), &contact, &policy, db::now_ms()) {
95
        Some(t) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], t).into_response(),
96
        None => (StatusCode::NOT_FOUND, "This hub has no security contact set.\n").into_response(),
97
    }
98
}
99
100
#[derive(Deserialize)]
101
pub struct SecurityForm {
102
    #[serde(rename = "_csrf")]
103
    csrf_token: String,
104
    #[serde(default)]
105
    contact: String,
106
    #[serde(default)]
107
    policy: String,
108
    /// Where reports go, shown on /security-contact.
109
    #[serde(default)]
110
    email: String,
111
    /// The page's text, Markdown; empty for the default.
112
    #[serde(default)]
113
    page: String,
114
}
115
116
/// `POST /admin/security`: the contact (an email address or https page)
117
/// and an optional https policy page; an empty contact takes the file down.
118
pub async fn save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SecurityForm>) -> Result<Page<super::admin::AdminPage>, Error> {
119
    let shell = Shell::new(&app, "/admin", &viewer);
120
    let me = super::admin::require_admin(&viewer, &shell)?;
121
    csrf(&viewer, &f.csrf_token, &shell)?;
122
    let (contact, policy) = (f.contact.trim(), f.policy.trim());
123
    if !contact.is_empty() && contact_uri(contact).is_none() {
124
        return Err(Error::bad(shell, "The contact is an email address or an https:// page."));
125
    }
126
    if !policy.is_empty() && (!policy.starts_with("https://") || policy.chars().any(char::is_whitespace)) {
127
        return Err(Error::bad(shell, "The policy is an https:// page, or empty."));
128
    }
129
    let (email, page) = (f.email.trim(), f.page.trim());
130
    if !email.is_empty() && contact_uri(email).is_none_or(|u| !u.starts_with("mailto:")) {
131
        return Err(Error::bad(shell, "The reporting address is an email address, or empty."));
132
    }
133
    if page.chars().count() > MAX_PAGE {
134
        return Err(Error::bad(shell, format!("The page text is at most {MAX_PAGE} characters.")));
135
    }
136
    let e = |e| Error::from_db(shell.clone(), e);
137
    accounts::set_setting(&app.db, CONTACT, contact).await.map_err(e)?;
138
    accounts::set_setting(&app.db, POLICY, policy).await.map_err(e)?;
139
    accounts::set_setting(&app.db, EMAIL, email).await.map_err(e)?;
140
    accounts::set_setting(&app.db, PAGE, page).await.map_err(e)?;
141
    accounts::audit(&app.db, Some(&me), "admin.security_contact", contact, policy).await.ok();
142
    let notice = if contact.is_empty() {
143
        "No security contact: /.well-known/security.txt is gone.".to_string()
144
    } else {
145
        "Security contact saved: /.well-known/security.txt names it.".to_string()
146
    };
147
    super::admin::admin_page(&app, shell, Some(notice)).await
148
}
149
150
#[cfg(test)]
151
mod tests {
152
    use super::*;
153
154
    #[test]
155
    fn contacts_become_uris_or_are_refused() {
156
        assert_eq!(contact_uri("[email protected]").as_deref(), Some("mailto:[email protected]"));
157
        assert_eq!(contact_uri(" https://example.org/report ").as_deref(), Some("https://example.org/report"));
158
        assert_eq!(contact_uri("mailto:[email protected]").as_deref(), Some("mailto:[email protected]"));
159
        for bad in ["", "http://example.org", "nobody", "a@b", "a [email protected]", "javascript:alert(1)", "[email protected]\nContact: evil"] {
160
            assert_eq!(contact_uri(bad), None, "{bad:?}");
161
        }
162
    }
163
164
    #[test]
165
    fn the_file_expires_under_a_year_ahead_and_is_canonical() {
166
        let now = 1_800_000_000_000; // 2027-01-15
167
        let t = text("https://hub.example/", "[email protected]", "https://hub.example/policy", now).unwrap();
168
        assert_eq!(
169
            t,
170
            "Contact: mailto:[email protected]\nExpires: 2028-01-10T08:00:00Z\nPolicy: https://hub.example/policy\n\
171
             Preferred-Languages: en\nCanonical: https://hub.example/.well-known/security.txt\n"
172
        );
173
        assert!(!text("https://hub.example", "[email protected]", "", now).unwrap().contains("Policy"));
174
        assert!(text("https://hub.example", "", "", now).is_none());
175
    }
176
}