OXYGENHUB
◆ diffusion-pin-bookmark oxygenhub / crates / hub / src / web / security_txt.rs
8.4 KiB · Rust · last changed Administration: a sidebar of grouped pages, one page per section (closes #288) 15 days ago source blame history raw
3dd6582d Andy McCall ↶ 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";
2cbf6f34 Andy McCall ↶ 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";
3dd6582d Andy McCall ↶ 32
/// How far ahead `Expires` is: under a year (RFC 9116 §2.5.5).
33
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000;
2cbf6f34 Andy McCall ↶ 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
}
3dd6582d Andy McCall ↶ 49
2cbf6f34 Andy McCall ↶ 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);
f59e9680 Andy McCall ↶ 53
    let (mut email, text) = page_settings(&app).await;
54
    // No address of its own: security.txt's, when that is an email.
55
    if email.is_empty() {
56
        let (contact, _) = settings(&app).await;
57
        email = contact_uri(&contact).and_then(|u| u.strip_prefix("mailto:").map(String::from)).unwrap_or_default();
58
    }
2cbf6f34 Andy McCall ↶ 59
    let text = if text.trim().is_empty() { DEFAULT_PAGE.to_string() } else { text };
60
    Page(ContactPage { shell, email, body_html: crate::render::markdown::render(&text, "", "") })
61
}
62
3dd6582d Andy McCall ↶ 63
/// The saved contact and policy (empty when unset).
64
pub async fn settings(app: &Shared) -> (String, String) {
65
    let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() };
66
    (get(CONTACT).await, get(POLICY).await)
67
}
68
69
/// A contact as the file gives it: an email address becomes a `mailto:`
70
/// URI; an https or mailto URI is kept. `None` for anything else.
71
pub fn contact_uri(contact: &str) -> Option<String> {
72
    let c = contact.trim();
73
    if c.is_empty() || c.chars().any(|ch| ch.is_whitespace() || ch.is_control()) {
74
        return None;
75
    }
76
    if c.starts_with("https://") || c.starts_with("mailto:") {
77
        return Some(c.to_string());
78
    }
79
    let (user, domain) = c.split_once('@')?;
80
    (!user.is_empty() && domain.contains('.') && !domain.contains('@') && !c.contains(':')).then(|| format!("mailto:{c}"))
81
}
82
83
/// The file's text, for a contact and policy, at `now_ms`.
84
pub fn text(base_url: &str, contact: &str, policy: &str, now_ms: i64) -> Option<String> {
85
    let contact = contact_uri(contact)?;
86
    let expires = crate::render::time::rfc3339((now_ms + EXPIRES_MS) as u64);
87
    let base = base_url.trim_end_matches('/');
88
    let mut out = format!("Contact: {contact}\nExpires: {expires}\n");
89
    if !policy.trim().is_empty() {
90
        out.push_str(&format!("Policy: {}\n", policy.trim()));
91
    }
92
    out.push_str(&format!("Preferred-Languages: en\nCanonical: {base}/.well-known/security.txt\n"));
93
    Some(out)
94
}
95
96
/// `GET /.well-known/security.txt` and `/security.txt`.
97
pub async fn serve(State(app): State<Shared>) -> Response {
98
    let (contact, policy) = settings(&app).await;
99
    match text(app.config.base_url(), &contact, &policy, db::now_ms()) {
100
        Some(t) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], t).into_response(),
101
        None => (StatusCode::NOT_FOUND, "This hub has no security contact set.\n").into_response(),
102
    }
103
}
104
105
#[derive(Deserialize)]
106
pub struct SecurityForm {
107
    #[serde(rename = "_csrf")]
108
    csrf_token: String,
109
    #[serde(default)]
110
    contact: String,
111
    #[serde(default)]
112
    policy: String,
2cbf6f34 Andy McCall ↶ 113
    /// Where reports go, shown on /security-contact.
114
    #[serde(default)]
115
    email: String,
116
    /// The page's text, Markdown; empty for the default.
117
    #[serde(default)]
118
    page: String,
3dd6582d Andy McCall ↶ 119
}
120
121
/// `POST /admin/security`: the contact (an email address or https page)
122
/// and an optional https policy page; an empty contact takes the file down.
123
pub async fn save(State(app): State<Shared>, viewer: Viewer, Form(f): Form<SecurityForm>) -> Result<Page<super::admin::AdminPage>, Error> {
124
    let shell = Shell::new(&app, "/admin", &viewer);
125
    let me = super::admin::require_admin(&viewer, &shell)?;
126
    csrf(&viewer, &f.csrf_token, &shell)?;
127
    let (contact, policy) = (f.contact.trim(), f.policy.trim());
128
    if !contact.is_empty() && contact_uri(contact).is_none() {
129
        return Err(Error::bad(shell, "The contact is an email address or an https:// page."));
130
    }
131
    if !policy.is_empty() && (!policy.starts_with("https://") || policy.chars().any(char::is_whitespace)) {
132
        return Err(Error::bad(shell, "The policy is an https:// page, or empty."));
2cbf6f34 Andy McCall ↶ 133
    }
134
    let (email, page) = (f.email.trim(), f.page.trim());
135
    if !email.is_empty() && contact_uri(email).is_none_or(|u| !u.starts_with("mailto:")) {
136
        return Err(Error::bad(shell, "The reporting address is an email address, or empty."));
137
    }
138
    if page.chars().count() > MAX_PAGE {
139
        return Err(Error::bad(shell, format!("The page text is at most {MAX_PAGE} characters.")));
3dd6582d Andy McCall ↶ 140
    }
141
    let e = |e| Error::from_db(shell.clone(), e);
142
    accounts::set_setting(&app.db, CONTACT, contact).await.map_err(e)?;
143
    accounts::set_setting(&app.db, POLICY, policy).await.map_err(e)?;
2cbf6f34 Andy McCall ↶ 144
    accounts::set_setting(&app.db, EMAIL, email).await.map_err(e)?;
145
    accounts::set_setting(&app.db, PAGE, page).await.map_err(e)?;
3dd6582d Andy McCall ↶ 146
    accounts::audit(&app.db, Some(&me), "admin.security_contact", contact, policy).await.ok();
147
    let notice = if contact.is_empty() {
148
        "No security contact: /.well-known/security.txt is gone.".to_string()
149
    } else {
150
        "Security contact saved: /.well-known/security.txt names it.".to_string()
151
    };
737de0fa Andy McCall ↶ 152
    super::admin::admin_page(&app, shell, super::admin::AdminSection::SecurityContact, Some(notice)).await
3dd6582d Andy McCall ↶ 153
}
154
155
#[cfg(test)]
156
mod tests {
157
    use super::*;
158
159
    #[test]
160
    fn contacts_become_uris_or_are_refused() {
161
        assert_eq!(contact_uri("[email protected]").as_deref(), Some("mailto:[email protected]"));
162
        assert_eq!(contact_uri(" https://example.org/report ").as_deref(), Some("https://example.org/report"));
163
        assert_eq!(contact_uri("mailto:[email protected]").as_deref(), Some("mailto:[email protected]"));
164
        for bad in ["", "http://example.org", "nobody", "a@b", "a [email protected]", "javascript:alert(1)", "[email protected]\nContact: evil"] {
165
            assert_eq!(contact_uri(bad), None, "{bad:?}");
166
        }
167
    }
168
169
    #[test]
170
    fn the_file_expires_under_a_year_ahead_and_is_canonical() {
171
        let now = 1_800_000_000_000; // 2027-01-15
172
        let t = text("https://hub.example/", "[email protected]", "https://hub.example/policy", now).unwrap();
173
        assert_eq!(
174
            t,
175
            "Contact: mailto:[email protected]\nExpires: 2028-01-10T08:00:00Z\nPolicy: https://hub.example/policy\n\
176
             Preferred-Languages: en\nCanonical: https://hub.example/.well-known/security.txt\n"
177
        );
178
        assert!(!text("https://hub.example", "[email protected]", "", now).unwrap().contains("Policy"));
179
        assert!(text("https://hub.example", "", "", now).is_none());
180
    }
181
}