| 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 |
const EMAIL: &str = "security_email"; |
| 20 |
const PAGE: &str = "security_page"; |
| 21 |
|
| 22 |
const MAX_PAGE: usize = 20_000; |
| 23 |
|
| 24 |
|
| 25 |
|
| 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 |
|
| 33 |
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000; |
| 34 |
|
| 35 |
|
| 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 |
|
| 47 |
body_html: String, |
| 48 |
} |
| 49 |
|
| 50 |
|
| 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 (mut email, text) = page_settings(&app).await; |
| 54 |
|
| 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 |
} |
| 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 |
|
| 63 |
|
| 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 |
|
| 70 |
|
| 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 |
|
| 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 |
|
| 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, |
| 113 |
|
| 114 |
#[serde(default)] |
| 115 |
email: String, |
| 116 |
|
| 117 |
#[serde(default)] |
| 118 |
page: String, |
| 119 |
} |
| 120 |
|
| 121 |
|
| 122 |
|
| 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.")); |
| 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."))); |
| 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)?; |
| 144 |
accounts::set_setting(&app.db, EMAIL, email).await.map_err(e)?; |
| 145 |
accounts::set_setting(&app.db, PAGE, page).await.map_err(e)?; |
| 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 |
}; |
| 152 |
super::admin::admin_page(&app, shell, Some(notice)).await |
| 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; |
| 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 |
} |