OXYGENHUB

A security contact page; HSTS from [server] hsts_max_age 2cbf6f348595

/security-contact shows the reporting address and Markdown text set under
Administration > Security contact, with text that makes no promises until
it is written (Closes #230). Strict-Transport-Security is sent with
max-age from [server] hsts_max_age (OXYHUB_SERVER_HSTS_MAX_AGE), only for an
https site, none at 0, so each stage is a config change (Closes #231).
live, csp-report, security.txt and security-contact are reserved names.
Tests: the page before and after it is set, a bad address refused; HSTS
off by default, never over http. docs/deploy.md.
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 08:09 UTC
Signature
✔ signed by Claude Code
Hash
2cbf6f348595a76d5d26de2df8f3c15859e9425ee879b07f3ae1c7116bf4e8e9
Depends on
19bb31ff1f5c 3556d08e17f9 3dd6582de060 93be9ce4653e 9a8e3b4419e0 bff9106edcba d23464c0ac60 d74ce22fa80e e71846aa07b9
Depended on by
84e3c09656b2 e57765dee61a e97500b70f9a 8a48dd830178 f59e9680d880
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
30 atoms, 1 tree operation, 11 files

Browse the tree at this change

added templates/security_contact.html +10 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Report a security problem{% endblock %}
3 +
{% block content %}
4 +
<div class="head"><h1>Report a security problem</h1></div>
5 +
<section class="panel prose">
6 +
{% if !email.is_empty() %}<p>Write to <strong><span class="secret">{{ email }}</span></strong> <button type="button" class="small" data-copy="{{ email }}">copy</button></p>{% endif %}
7 +
{{ body_html|safe }}
8 +
<p class="small mute">This page is also named in <a href="/.well-known/security.txt">security.txt</a>.</p>
9 +
</section>
10 +
{% endblock %}
modified crates/hub/src/config.rs +14 −1
…
179 unchanged lines
180 180
    pub base_url: String,
181 181
    /// Trust `X-Forwarded-*` from a terminating proxy.
182 182
    pub behind_proxy: bool,
183 +
    /// Strict-Transport-Security's max-age in seconds, sent when
184 +
    /// `base_url` is https; 0 sends none (oxygenhub#231). Raise it in
185 +
    /// stages: browsers keep it, so it is hard to take back.
186 +
    pub hsts_max_age: u64,
183 187
}
184 188
185 189
#[derive(Clone, Debug, Deserialize)]
…
83 unchanged lines
269 273
270 274
impl Default for Server {
271 275
    fn default() -> Self {
272 -
        Server { bind: "127.0.0.1:7300".into(), tls_bind: String::new(), base_url: "http://127.0.0.1:7300".into(), behind_proxy: false }
276 +
        Server {
277 +
            bind: "127.0.0.1:7300".into(),
278 +
            tls_bind: String::new(),
279 +
            base_url: "http://127.0.0.1:7300".into(),
280 +
            behind_proxy: false,
281 +
            hsts_max_age: 0,
282 +
        }
273 283
    }
274 284
}
275 285
…
73 unchanged lines
349 359
        }
350 360
        if let Some(v) = get("OXYHUB_SERVER_BEHIND_PROXY") {
351 361
            self.server.behind_proxy = matches!(v.as_str(), "1" | "true" | "yes");
362 +
        }
363 +
        if let Some(v) = get("OXYHUB_SERVER_HSTS_MAX_AGE") {
364 +
            self.server.hsts_max_age = v.parse().unwrap_or(0);
352 365
        }
353 366
        if let Some(v) = get("OXYHUB_TLS_SELF_SIGNED") {
354 367
            self.tls.self_signed = matches!(v.as_str(), "1" | "true" | "yes");
…
82 unchanged lines
modified crates/hub/src/headers.rs +16 −3
…
6 unchanged lines
7 7
//! enforcing is one constant ([`ENFORCE`]) once the reports are quiet.
8 8
//! Responses that set their own policy (report frames, #127; a job's
9 9
//! HTML artifact) keep theirs, and may be framed as they say.
10 -
//! Strict-Transport-Security is deliberately not sent yet: browsers keep
11 -
//! it for months, so it waits for the site owner's go.
10 +
//! Strict-Transport-Security is sent only when `[server] hsts_max_age` is
11 +
//! set (oxygenhub#231), raised in stages: browsers keep it.
12 12
13 13
use axum::http::{header, HeaderName, HeaderValue};
14 14
use axum::response::Response;
…
17 unchanged lines
32 32
const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), bluetooth=(), \
33 33
midi=(), magnetometer=(), gyroscope=(), accelerometer=(), display-capture=(), browsing-topics=(), interest-cohort=()";
34 34
35 +
/// The Strict-Transport-Security value for a site, if it sends one
36 +
/// (oxygenhub#231): only for an https address and a max-age above 0.
37 +
pub fn hsts(base_url: &str, max_age: u64) -> Option<HeaderValue> {
38 +
    (base_url.starts_with("https://") && max_age > 0).then(|| HeaderValue::from_str(&format!("max-age={max_age}")).ok()).flatten()
39 +
}
40 +
35 41
/// Adds the headers a response does not already carry.
36 -
pub async fn security_headers(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
42 +
pub async fn security_headers(
43 +
    axum::extract::State(hsts): axum::extract::State<Option<HeaderValue>>,
44 +
    req: axum::extract::Request,
45 +
    next: axum::middleware::Next,
46 +
) -> Response {
37 47
    let mut res = next.run(req).await;
38 48
    let h = res.headers_mut();
49 +
    if let Some(v) = hsts {
50 +
        h.entry(header::STRICT_TRANSPORT_SECURITY).or_insert(v);
51 +
    }
39 52
    let own_policy = h.contains_key(header::CONTENT_SECURITY_POLICY);
40 53
    let mut set = |name: HeaderName, value: &'static str| {
41 54
        h.entry(name).or_insert(HeaderValue::from_static(value));
…
125 unchanged lines
modified crates/hub/src/lib.rs +2 −1
…
164 unchanged lines
165 165
/// The full router: pages, API, protocol and static assets.
166 166
pub fn router(app: Shared) -> axum::Router {
167 167
    let https = app_https(&app);
168 +
    let hsts = headers::hsts(app.config.base_url(), app.config.server.hsts_max_age);
168 169
    web::router(app.clone())
169 170
        .layer(axum::middleware::from_fn_with_state(app, agent_requests))
170 171
        .layer(axum::middleware::from_fn(private_pages))
171 172
        .layer(axum::middleware::from_fn(slow_requests))
172 173
        .layer(axum::middleware::from_fn_with_state(https, https_only))
173 -
        .layer(axum::middleware::from_fn(headers::security_headers))
174 +
        .layer(axum::middleware::from_fn_with_state(hsts, headers::security_headers))
174 175
}
175 176
176 177
/// The site's https address, when it has one: what [`https_only`] redirects to.
…
132 unchanged lines
modified crates/hub/src/repos/names.rs +5 −0
…
32 unchanged lines
33 33
    "projects",
34 34
    "assets",
35 35
    ".well-known",
36 +
    // Live pages, CSP reports and the security pages (oxygenhub#204, #222, #224, #230).
37 +
    "live",
38 +
    "csp-report",
39 +
    "security.txt",
40 +
    "security-contact",
36 41
];
37 42
38 43
/// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`.
…
60 unchanged lines
modified crates/hub/src/web/admin.rs +8 −0
…
40 unchanged lines
41 41
    /// The security.txt contact and policy (oxygenhub#224).
42 42
    security_contact: String,
43 43
    security_policy: String,
44 +
    /// The /security-contact page's address and text (oxygenhub#230).
45 +
    security_email: String,
46 +
    security_page: String,
47 +
    security_default: &'static str,
44 48
}
45 49
46 50
pub(super) fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> {
…
34 unchanged lines
81 85
    let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?;
82 86
    let providers = super::signin::provider_views(app).await.map_err(e)?;
83 87
    let (security_contact, security_policy) = super::security_txt::settings(app).await;
88 +
    let (security_email, security_page) = super::security_txt::page_settings(app).await;
84 89
    Ok(Page(AdminPage {
85 90
        providers,
86 91
        security_contact,
87 92
        security_policy,
93 +
        security_email,
94 +
        security_page,
95 +
        security_default: super::security_txt::DEFAULT_PAGE,
88 96
        assistants,
89 97
        variables,
90 98
        secrets,
…
388 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
82 unchanged lines
83 83
        .route("/.well-known/jwks.json", get(crate::oidc::jwks))
84 84
        .route("/.well-known/security.txt", get(security_txt::serve))
85 85
        .route("/security.txt", get(security_txt::serve))
86 +
        .route("/security-contact", get(security_txt::contact_page))
86 87
        .route("/style.css", get(statics::style))
87 88
        .route("/favicon.svg", get(statics::favicon))
88 89
        .route("/favicon.ico", get(statics::favicon))
…
697 unchanged lines
modified crates/hub/src/web/security_txt.rs +51 −0
…
15 unchanged lines
16 16
17 17
const CONTACT: &str = "security_contact";
18 18
const POLICY: &str = "security_policy";
19 +
const EMAIL: &str = "security_email";
20 +
const PAGE: &str = "security_page";
21 +
/// The longest page text kept.
22 +
const MAX_PAGE: usize = 20_000;
23 +
24 +
/// What `/security-contact` says until an administrator writes their own
25 +
/// (oxygenhub#230). It makes no promises on the site's behalf.
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";
19 32
/// How far ahead `Expires` is: under a year (RFC 9116 §2.5.5).
20 33
const EXPIRES_MS: i64 = 360 * 24 * 60 * 60_000;
34 +
35 +
/// The reporting address and the page text (empty when unset).
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 +
    /// The page text as HTML, sanitised by the Markdown renderer.
47 +
    body_html: String,
48 +
}
21 49
50 +
/// `GET /security-contact`: how to report a security problem (oxygenhub#230).
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 (email, text) = page_settings(&app).await;
54 +
    let text = if text.trim().is_empty() { DEFAULT_PAGE.to_string() } else { text };
55 +
    Page(ContactPage { shell, email, body_html: crate::render::markdown::render(&text, "", "") })
56 +
}
57 +
22 58
/// The saved contact and policy (empty when unset).
23 59
pub async fn settings(app: &Shared) -> (String, String) {
24 60
    let get = |k| async move { accounts::setting(&app.db, k).await.ok().flatten().unwrap_or_default() };
…
44 unchanged lines
69 105
    contact: String,
70 106
    #[serde(default)]
71 107
    policy: String,
108 +
    /// Where reports go, shown on /security-contact.
109 +
    #[serde(default)]
110 +
    email: String,
111 +
    /// The page's text, Markdown; empty for the default.
112 +
    #[serde(default)]
113 +
    page: String,
72 114
}
73 115
74 116
/// `POST /admin/security`: the contact (an email address or https page)
…
8 unchanged lines
83 125
    }
84 126
    if !policy.is_empty() && (!policy.starts_with("https://") || policy.chars().any(char::is_whitespace)) {
85 127
        return Err(Error::bad(shell, "The policy is an https:// page, or empty."));
128 +
    }
129 +
    let (email, page) = (f.email.trim(), f.page.trim());
130 +
    if !email.is_empty() && contact_uri(email).is_none_or(|u| !u.starts_with("mailto:")) {
131 +
        return Err(Error::bad(shell, "The reporting address is an email address, or empty."));
132 +
    }
133 +
    if page.chars().count() > MAX_PAGE {
134 +
        return Err(Error::bad(shell, format!("The page text is at most {MAX_PAGE} characters.")));
86 135
    }
87 136
    let e = |e| Error::from_db(shell.clone(), e);
88 137
    accounts::set_setting(&app.db, CONTACT, contact).await.map_err(e)?;
89 138
    accounts::set_setting(&app.db, POLICY, policy).await.map_err(e)?;
139 +
    accounts::set_setting(&app.db, EMAIL, email).await.map_err(e)?;
140 +
    accounts::set_setting(&app.db, PAGE, page).await.map_err(e)?;
90 141
    accounts::audit(&app.db, Some(&me), "admin.security_contact", contact, policy).await.ok();
91 142
    let notice = if contact.is_empty() {
92 143
        "No security contact: /.well-known/security.txt is gone.".to_string()
…
33 unchanged lines
modified crates/hub/tests/accounts.rs +33 −0
…
1241 unchanged lines
1242 1242
    .await;
1243 1243
    let cookie = r.headers().get(header::SET_COOKIE).expect("signed in").to_str().unwrap();
1244 1244
    assert!(cookie.contains("; Secure") && cookie.contains("HttpOnly"), "{cookie}");
1245 +
    // No HSTS until a max-age is set (oxygenhub#231).
1246 +
    assert!(call("GET", "/login", Some(("x-forwarded-proto", "https")), None)
1247 +
        .await
1248 +
        .headers()
1249 +
        .get(header::STRICT_TRANSPORT_SECURITY)
1250 +
        .is_none());
1251 +
    assert_eq!(oxygenhub::headers::hsts("https://hub.example.org", 300).unwrap(), "max-age=300");
1252 +
    assert!(oxygenhub::headers::hsts("http://127.0.0.1:7300", 300).is_none(), "never over plain http");
1253 +
    assert!(oxygenhub::headers::hsts("https://hub.example.org", 0).is_none());
1245 1254
}
1246 1255
1247 1256
#[tokio::test]
…
111 unchanged lines
1359 1368
    // An empty contact takes the file down.
1360 1369
    set("", "").await;
1361 1370
    assert_eq!(get(app, "/security.txt", None).await.status, StatusCode::NOT_FOUND);
1371 +
1372 +
    // /security-contact (oxygenhub#230): sensible text until it is written,
1373 +
    // then the address and the administrator's own text.
1374 +
    let page = get(app, "/security-contact", None).await;
1375 +
    assert_eq!(page.status, StatusCode::OK);
1376 +
    assert!(page.body.contains("Report a security problem") && page.body.contains("tell us privately"), "{}", page.body);
1377 +
    let r = post(
1378 +
        app,
1379 +
        "/admin/security",
1380 +
        Some(&andy),
1381 +
        &format!(
1382 +
            "_csrf={csrf}&contact=https%3A%2F%2Fhub.example%2Fsecurity-contact&email=security%40example.org&page=Mail+us.+**Thank+you.**"
1383 +
        ),
1384 +
    )
1385 +
    .await;
1386 +
    assert!(r.body.contains("Security contact saved"), "{}", r.body);
1387 +
    let page = get(app, "/security-contact", None).await.body;
1388 +
    assert!(
1389 +
        page.contains("[email protected]") && page.contains("<strong>Thank you.</strong>") && !page.contains("tell us privately"),
1390 +
        "{page}"
1391 +
    );
1392 +
    let r = post(app, "/admin/security", Some(&andy), &format!("_csrf={csrf}&contact=sec%40example.org&email=not-an-address")).await;
1393 +
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
1394 +
    assert!(!oxygenhub::repos::names::valid_owner("security-contact") && !oxygenhub::repos::names::valid_owner("live"), "reserved");
1362 1395
}
modified docs/deploy.md +9 −3
…
45 unchanged lines
46 46
cookie it sets is `Secure`. Requests made to the hub directly, such as a
47 47
runner on the same host using `http://127.0.0.1:7300`, carry no such
48 48
header and are served as before. In front, Cloudflare's Always Use HTTPS
49 -
is on (2026-10-15). Strict-Transport-Security is not sent yet: the security headers (#222)
50 -
are in, and it waits for the site owner's decision, because browsers
51 -
remember it for months.
49 +
is on (2026-10-15). Strict-Transport-Security comes from `[server] hsts_max_age` (seconds;
50 +
0, the default, sends none; never over plain http; oxygenhub#231).
51 +
Browsers remember it, so raise it in stages: 300 first, a week
52 +
(604800) after a few quiet days, then a year (31536000). Add
53 +
`includeSubDomains` only once every subdomain is HTTPS, and preload last.
54 +
oxygenhub.io is at 300 from 2026-10-16.
52 55
53 56
## Security headers (oxygenhub#222)
54 57
…
18 unchanged lines
73 76
hub then serves `/.well-known/security.txt` (and `/security.txt`) per
74 77
RFC 9116, with an `Expires` date that is always just under a year ahead,
75 78
so it never goes stale. With no contact set, both answer 404.
79 +
`/security-contact` is the page to point the contact at: the reporting
80 +
address and Markdown text from the same panel (oxygenhub#230), with
81 +
sensible text until it is written.
76 82
77 83
## Guessing is slowed (oxygenhub#221)
78 84
…
30 unchanged lines
modified templates/admin.html +2 −0
…
92 unchanged lines
93 93
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
94 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 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 +
    <label for="secemail">Reporting address, shown on <a href="/security-contact">/security-contact</a></label><input type="email" id="secemail" name="email" value="{{ security_email }}" placeholder="[email protected]" autocomplete="off">
97 +
    <label for="secpage">That page's text (Markdown; empty for the text below)</label><textarea id="secpage" name="page" rows="8" placeholder="{{ security_default }}">{{ security_page }}</textarea>
96 98
    <button type="submit">SAVE</button>
97 99
  </form>
98 100
  <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>
…
14 unchanged lines