OXYGENHUB

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