//! `security.txt` (RFC 9116, oxygenhub#224): where to report a //! vulnerability in this hub. A site administrator sets the contact and, //! if there is one, a policy page under Administration; until a contact is //! set there is no file (404). `Expires` is always just under a year ahead, //! as the RFC asks, so the file never goes stale. use super::settings::csrf; use super::{Error, Page, Shell}; use crate::auth::Viewer; use crate::db::{self, accounts}; use crate::Shared; use axum::extract::{Form, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; use serde::Deserialize; const CONTACT: &str = "security_contact"; const POLICY: &str = "security_policy"; /// How far ahead `Expires` is: under a year (RFC 9116 ยง2.5.5). const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000; /// The saved contact and policy (empty when unset). pub async fn settings(app: &Shared) -> (String, String) { let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() }; (get(CONTACT).await, get(POLICY).await) } /// A contact as the file gives it: an email address becomes a `mailto:` /// URI; an https or mailto URI is kept. `None` for anything else. pub fn contact_uri(contact: &str) -> Option { let c = contact.trim(); if c.is_empty() || c.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { return None; } if c.starts_with("https://") || c.starts_with("mailto:") { return Some(c.to_string()); } let (user, domain) = c.split_once('@')?; (!user.is_empty() && domain.contains('.') && !domain.contains('@') && !c.contains(':')).then(|| format!("mailto:{c}")) } /// The file's text, for a contact and policy, at `now_ms`. pub fn text(base_url: &str, contact: &str, policy: &str, now_ms: i64) -> Option { let contact = contact_uri(contact)?; let expires = crate::render::time::rfc3339((now_ms + EXPIRES_MS) as u64); let base = base_url.trim_end_matches('/'); let mut out = format!("Contact: {contact}\nExpires: {expires}\n"); if !policy.trim().is_empty() { out.push_str(&format!("Policy: {}\n", policy.trim())); } out.push_str(&format!("Preferred-Languages: en\nCanonical: {base}/.well-known/security.txt\n")); Some(out) } /// `GET /.well-known/security.txt` and `/security.txt`. pub async fn serve(State(app): State) -> Response { let (contact, policy) = settings(&app).await; match text(app.config.base_url(), &contact, &policy, db::now_ms()) { Some(t) => ([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], t).into_response(), None => (StatusCode::NOT_FOUND, "This hub has no security contact set.\n").into_response(), } } #[derive(Deserialize)] pub struct SecurityForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] contact: String, #[serde(default)] policy: String, } /// `POST /admin/security`: the contact (an email address or https page) /// and an optional https policy page; an empty contact takes the file down. pub async fn save(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/admin", &viewer); let me = super::admin::require_admin(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (contact, policy) = (f.contact.trim(), f.policy.trim()); if !contact.is_empty() && contact_uri(contact).is_none() { return Err(Error::bad(shell, "The contact is an email address or an https:// page.")); } if !policy.is_empty() && (!policy.starts_with("https://") || policy.chars().any(char::is_whitespace)) { return Err(Error::bad(shell, "The policy is an https:// page, or empty.")); } let e = |e| Error::from_db(shell.clone(), e); accounts::set_setting(&app.db, CONTACT, contact).await.map_err(e)?; accounts::set_setting(&app.db, POLICY, policy).await.map_err(e)?; accounts::audit(&app.db, Some(&me), "admin.security_contact", contact, policy).await.ok(); let notice = if contact.is_empty() { "No security contact: /.well-known/security.txt is gone.".to_string() } else { "Security contact saved: /.well-known/security.txt names it.".to_string() }; super::admin::admin_page(&app, shell, Some(notice)).await } #[cfg(test)] mod tests { use super::*; #[test] fn contacts_become_uris_or_are_refused() { assert_eq!(contact_uri("security@example.org").as_deref(), Some("mailto:security@example.org")); assert_eq!(contact_uri(" https://example.org/report ").as_deref(), Some("https://example.org/report")); assert_eq!(contact_uri("mailto:a@example.org").as_deref(), Some("mailto:a@example.org")); for bad in ["", "http://example.org", "nobody", "a@b", "a b@example.org", "javascript:alert(1)", "x@y.org\nContact: evil"] { assert_eq!(contact_uri(bad), None, "{bad:?}"); } } #[test] fn the_file_expires_under_a_year_ahead_and_is_canonical() { let now = 1_800_000_000_000; // 2027-01-15 let t = text("https://hub.example/", "sec@hub.example", "https://hub.example/policy", now).unwrap(); assert_eq!( t, "Contact: mailto:sec@hub.example\nExpires: 2028-01-10T08:00:00Z\nPolicy: https://hub.example/policy\n\ Preferred-Languages: en\nCanonical: https://hub.example/.well-known/security.txt\n" ); assert!(!text("https://hub.example", "sec@hub.example", "", now).unwrap().contains("Policy")); assert!(text("https://hub.example", "", "", now).is_none()); } }