security.txt from a contact set under Administration 3dd6582de060
/.well-known/security.txt and /security.txt (RFC 9116): Contact (an email becomes mailto:, or an https page), optional Policy, Expires renewed to just under a year ahead on every request, Preferred-Languages and Canonical. 404 until an administrator sets a contact; changes are audited. Closes #224
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 00:36 UTC
- Signature
- ✔ signed by Claude Code
- Hash
3dd6582de0606a8aeb4dcb1bd92f41842aa12e787c93f090d64bdc409c40548c- Depends on
- 19bb31ff1f5c 2a83f5303458 3556d08e17f9 6ae3fa0af4f6 83940d53664f 93be9ce4653e aa0bfbd11f02 c9aacc0e7dc1 d1727c573f30 d23464c0ac60 d74ce22fa80e e71846aa07b9 e8e7158bbc73
- Depended on by
- 737de0fac722 965b950f9b96 f59e9680d880 2cbf6f348595 67be8ac00c24 0024ccb1b050
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 14 atoms, 1 tree operation, 6 files
Browse the tree at this change
added crates/hub/src/web/security_txt.rs +125 −0
| 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 | + | /// How far ahead `Expires` is: under a year (RFC 9116 §2.5.5). |
|
| 20 | + | const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000; |
|
| 21 | + | ||
| 22 | + | /// The saved contact and policy (empty when unset). |
|
| 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 | + | /// A contact as the file gives it: an email address becomes a `mailto:` |
|
| 29 | + | /// URI; an https or mailto URI is kept. `None` for anything else. |
|
| 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 | + | /// The file's text, for a contact and policy, at `now_ms`. |
|
| 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 | + | /// `GET /.well-known/security.txt` and `/security.txt`. |
|
| 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 | + | /// `POST /admin/security`: the contact (an email address or https page) |
|
| 75 | + | /// and an optional https policy page; an empty contact takes the file down. |
|
| 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; // 2027-01-15 |
|
| 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 | + | } |
modified crates/hub/src/web/admin.rs +8 −2
| … | 37 unchanged lines |
||
| 38 | 38 | secrets: super::secrets::Panel, |
|
| 39 | 39 | /// Sign-in providers (#106). |
|
| 40 | 40 | providers: Vec<super::signin::ProviderView>, |
|
| 41 | + | /// The security.txt contact and policy (oxygenhub#224). |
|
| 42 | + | security_contact: String, |
|
| 43 | + | security_policy: String, |
|
| 41 | 44 | } |
|
| 42 | 45 | ||
| 43 | - | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| 46 | + | pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| 44 | 47 | match viewer.account() { |
|
| 45 | 48 | Some(a) if a.is_admin => Ok(a.clone()), |
|
| 46 | 49 | _ => Err(Error::not_found(shell.clone())), |
|
| 47 | 50 | } |
|
| 48 | 51 | } |
|
| 49 | 52 | ||
| 50 | - | async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> { |
|
| 53 | + | pub(super) async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> { |
|
| 51 | 54 | admin_page_at(app, shell, notice, None).await |
|
| 52 | 55 | } |
|
| 53 | 56 | ||
| … | 23 unchanged lines |
||
| 77 | 80 | let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 78 | 81 | let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 79 | 82 | let providers = super::signin::provider_views(app).await.map_err(e)?; |
|
| 83 | + | let (security_contact, security_policy) = super::security_txt::settings(app).await; |
|
| 80 | 84 | Ok(Page(AdminPage { |
|
| 81 | 85 | providers, |
|
| 86 | + | security_contact, |
|
| 87 | + | security_policy, |
|
| 82 | 88 | assistants, |
|
| 83 | 89 | variables, |
|
| 84 | 90 | secrets, |
|
| … | 388 unchanged lines |
modified crates/hub/src/web/mod.rs +4 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub mod history; |
|
| 20 | 20 | pub mod home; |
|
| 21 | 21 | pub mod icons; |
|
| 22 | + | mod security_txt; |
|
| 22 | 23 | ||
| 23 | 24 | /// A state as people read it (oxygenhub#170): `02a6c9d4746a+… (14 heads)` |
|
| 24 | 25 | /// for heads joined by `+`, which can run to hundreds of characters; a |
|
| … | 55 unchanged lines |
||
| 80 | 81 | .route("/.well-known/oxyhub-ca.pem", get(home::hub_ca)) |
|
| 81 | 82 | .route("/.well-known/openid-configuration", get(crate::oidc::discovery)) |
|
| 82 | 83 | .route("/.well-known/jwks.json", get(crate::oidc::jwks)) |
|
| 84 | + | .route("/.well-known/security.txt", get(security_txt::serve)) |
|
| 85 | + | .route("/security.txt", get(security_txt::serve)) |
|
| 83 | 86 | .route("/style.css", get(statics::style)) |
|
| 84 | 87 | .route("/favicon.svg", get(statics::favicon)) |
|
| 85 | 88 | .route("/favicon.ico", get(statics::favicon)) |
|
| … | 67 unchanged lines |
||
| 153 | 156 | .route("/admin/assistants", post(admin::assistants)) |
|
| 154 | 157 | .route("/admin/assistants/models", post(admin::assistant_models)) |
|
| 155 | 158 | .route("/admin/signin", post(admin::signin)) |
|
| 159 | + | .route("/admin/security", post(security_txt::save)) |
|
| 156 | 160 | .route("/login/{provider}", get(signin::start)) |
|
| 157 | 161 | .route("/login/{provider}/callback", get(signin::callback)) |
|
| 158 | 162 | .route("/settings/security/connect/{provider}", get(signin::connect)) |
|
| … | 623 unchanged lines |
modified crates/hub/tests/accounts.rs +34 −0
| … | 1325 unchanged lines |
||
| 1326 | 1326 | let big = Request::post("/csp-report").body(Body::from(vec![b' '; 64 * 1024])).unwrap(); |
|
| 1327 | 1327 | assert_eq!(oxygenhub::router(h.app.clone()).oneshot(big).await.unwrap().status(), StatusCode::PAYLOAD_TOO_LARGE); |
|
| 1328 | 1328 | } |
|
| 1329 | + | ||
| 1330 | + | #[tokio::test] |
|
| 1331 | + | async fn security_txt_names_the_contact_an_administrator_sets() { |
|
| 1332 | + | let h = hub().await; |
|
| 1333 | + | let app = &h.app; |
|
| 1334 | + | assert_eq!(get(app, "/.well-known/security.txt", None).await.status, StatusCode::NOT_FOUND, "no contact, no file"); |
|
| 1335 | + | let andy = register(app, "andy", "").await; |
|
| 1336 | + | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 1337 | + | assert!(page.contains("No contact yet"), "the panel"); |
|
| 1338 | + | let csrf = csrf_of(&page); |
|
| 1339 | + | let set = |contact: &'static str, policy: &'static str| { |
|
| 1340 | + | let (andy, csrf) = (andy.clone(), csrf.clone()); |
|
| 1341 | + | async move { post(app, "/admin/security", Some(&andy), &format!("_csrf={csrf}&contact={contact}&policy={policy}")).await } |
|
| 1342 | + | }; |
|
| 1343 | + | assert_eq!(set("javascript%3Aalert(1)", "").await.status, StatusCode::BAD_REQUEST); |
|
| 1344 | + | assert_eq!(set("sec%40example.org", "http%3A%2F%2Fexample.org").await.status, StatusCode::BAD_REQUEST, "policy over https only"); |
|
| 1345 | + | let r = set("sec%40example.org", "https%3A%2F%2Fexample.org%2Fpolicy").await; |
|
| 1346 | + | assert!(r.body.contains("Security contact saved"), "{}", r.body); |
|
| 1347 | + | for path in ["/.well-known/security.txt", "/security.txt"] { |
|
| 1348 | + | let r = get(app, path, None).await; |
|
| 1349 | + | assert_eq!(r.status, StatusCode::OK); |
|
| 1350 | + | assert!(r.body.starts_with("Contact: mailto:[email protected]\nExpires: 20"), "{}", r.body); |
|
| 1351 | + | assert!(r.body.contains("Policy: https://example.org/policy\n") && r.body.contains("/.well-known/security.txt\n"), "{}", r.body); |
|
| 1352 | + | } |
|
| 1353 | + | // Someone not an administrator cannot change it. |
|
| 1354 | + | let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 1355 | + | let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 1356 | + | let bob = register(app, "bob", &code).await; |
|
| 1357 | + | let r = post(app, "/admin/security", Some(&bob), &format!("_csrf={csrf}&contact=x%40evil.example")).await; |
|
| 1358 | + | assert_ne!(r.status, StatusCode::OK); |
|
| 1359 | + | // An empty contact takes the file down. |
|
| 1360 | + | set("", "").await; |
|
| 1361 | + | assert_eq!(get(app, "/security.txt", None).await.status, StatusCode::NOT_FOUND); |
|
| 1362 | + | } |
modified docs/deploy.md +8 −0
| … | 65 unchanged lines |
||
| 66 | 66 | policy (report frames, a job's HTML artifact) keep it. HSTS is not sent: |
|
| 67 | 67 | it waits for the site owner's decision. |
|
| 68 | 68 | ||
| 69 | + | ## security.txt (oxygenhub#224) |
|
| 70 | + | ||
| 71 | + | Administration › Security contact sets where vulnerabilities are reported |
|
| 72 | + | (an email address or an https page) and, optionally, a policy page. The |
|
| 73 | + | hub then serves `/.well-known/security.txt` (and `/security.txt`) per |
|
| 74 | + | RFC 9116, with an `Expires` date that is always just under a year ahead, |
|
| 75 | + | so it never goes stale. With no contact set, both answer 404. |
|
| 76 | + | ||
| 69 | 77 | ## Guessing is slowed (oxygenhub#221) |
|
| 70 | 78 | ||
| 71 | 79 | Password sign-in, the two-factor code step, device codes and password |
|
| … | 29 unchanged lines |
modified templates/admin.html +11 −0
| … | 11 unchanged lines |
||
| 12 | 12 | <a href="#organisations">Organisations</a> |
|
| 13 | 13 | <a href="#signin">Sign in with</a> |
|
| 14 | 14 | <a href="#mail">Mail</a> |
|
| 15 | + | <a href="#security">Security contact</a> |
|
| 15 | 16 | <a href="#variables">Variables</a> |
|
| 16 | 17 | <a href="#secrets">Secrets</a> |
|
| 17 | 18 | <a href="#assistants">AI assistants</a> |
|
| … | 68 unchanged lines |
||
| 86 | 87 | <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form> |
|
| 87 | 88 | <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p> |
|
| 88 | 89 | </section> |
|
| 90 | + | <section class="panel" id="security"> |
|
| 91 | + | <h2>Security contact</h2> |
|
| 92 | + | <form class="card" method="post" action="/admin/security"> |
|
| 93 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 94 | + | <label for="seccontact">Contact</label><input type="text" id="seccontact" name="contact" value="{{ security_contact }}" placeholder="[email protected] or https://example.org/report" autocomplete="off"> |
|
| 95 | + | <label for="secpolicy">Policy page</label><input type="url" id="secpolicy" name="policy" value="{{ security_policy }}" placeholder="https://example.org/security-policy (optional)" autocomplete="off"> |
|
| 96 | + | <button type="submit">SAVE</button> |
|
| 97 | + | </form> |
|
| 98 | + | <p class="small mute">{% if security_contact.is_empty() %}No contact yet, so there is no <code>security.txt</code>.{% else %}Published at <a href="/.well-known/security.txt">/.well-known/security.txt</a>.{% endif %} Researchers look there (RFC 9116) for where to report a vulnerability in this hub. Its expiry date renews itself.</p> |
|
| 99 | + | </section> |
|
| 89 | 100 | {% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %} |
|
| 90 | 101 | {% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %} |
|
| 91 | 102 | {% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %} |
|
| … | 10 unchanged lines |