| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 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 |
|
| 20 |
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000; |
| 21 |
|
| 22 |
|
| 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 |
|
| 29 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 75 |
|
| 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; |
| 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 |
} |