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 |