HTTPS only: nothing taken over plain http through a proxy 9a8e3b4419e0
On a site whose base_url is https, a request X-Forwarded-Proto (or Cloudflare's CF-Visitor) reports as plain http is redirected to the same path on base_url (GET, HEAD) or refused with 400 (anything else), so a form or a password is never taken over http. Requests made to the hub directly (its runners on 127.0.0.1) are untouched. Every cookie the hub sets is Secure on an https site (theme, backdrop and sign-out now too). HSTS waits for #222 and the site owner's decision (docs/deploy.md). Closes #218
- 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-15 22:51 UTC
- Signature
- ✔ signed by Claude Code
- Hash
9a8e3b4419e05574a3a5bb5d37fda6b1d904fb842bf91608237fd4b0699b758e- Depends on
- 17ca6cddc534 19bb31ff1f5c 2a0c09c09716 93be9ce4653e baebdd18aaa2 bff9106edcba c40c2d3edb51 d23464c0ac60
- Depended on by
- e57765dee61a c8684d44d549 2cbf6f348595 d74ce22fa80e aa0bfbd11f02 9a777fec4ed9
- 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
- 10 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/hub/src/lib.rs +41 −0
| … | 157 unchanged lines |
||
| 158 | 158 | ||
| 159 | 159 | /// The full router: pages, API, protocol and static assets. |
|
| 160 | 160 | pub fn router(app: Shared) -> axum::Router { |
|
| 161 | + | let https = app_https(&app); |
|
| 161 | 162 | web::router(app.clone()) |
|
| 162 | 163 | .layer(axum::middleware::from_fn_with_state(app, agent_requests)) |
|
| 163 | 164 | .layer(axum::middleware::from_fn(private_pages)) |
|
| 164 | 165 | .layer(axum::middleware::from_fn(slow_requests)) |
|
| 166 | + | .layer(axum::middleware::from_fn_with_state(https, https_only)) |
|
| 165 | 167 | } |
|
| 166 | 168 | ||
| 169 | + | /// The site's https address, when it has one: what [`https_only`] redirects to. |
|
| 170 | + | fn app_https(app: &Shared) -> Option<String> { |
|
| 171 | + | let base = app.config.base_url(); |
|
| 172 | + | base.starts_with("https://").then(|| base.trim_end_matches('/').to_string()) |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | /// The scheme a proxy in front of the hub says the visitor used |
|
| 176 | + | /// (`X-Forwarded-Proto`, else Cloudflare's `CF-Visitor`); `None` for a |
|
| 177 | + | /// request made to the hub directly (runners and tools on its host). |
|
| 178 | + | fn forwarded_scheme(headers: &axum::http::HeaderMap) -> Option<String> { |
|
| 179 | + | if let Some(p) = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()) { |
|
| 180 | + | return p.split(',').next().map(|s| s.trim().to_ascii_lowercase()); |
|
| 181 | + | } |
|
| 182 | + | let visitor = headers.get("cf-visitor").and_then(|v| v.to_str().ok())?; |
|
| 183 | + | serde_json::from_str::<serde_json::Value>(visitor).ok()?["scheme"].as_str().map(str::to_ascii_lowercase) |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// HTTPS only (oxygenhub#218): on a site whose address is https, a request |
|
| 187 | + | /// a proxy reports as plain http is sent to the https address (GET and |
|
| 188 | + | /// HEAD) or refused (anything else, so a form or a password is never taken |
|
| 189 | + | /// over http). Requests made to the hub directly carry no such report and |
|
| 190 | + | /// are untouched. |
|
| 191 | + | async fn https_only( |
|
| 192 | + | axum::extract::State(https): axum::extract::State<Option<String>>, |
|
| 193 | + | req: axum::extract::Request, |
|
| 194 | + | next: axum::middleware::Next, |
|
| 195 | + | ) -> axum::response::Response { |
|
| 196 | + | use axum::response::IntoResponse; |
|
| 197 | + | let Some(base) = https else { return next.run(req).await }; |
|
| 198 | + | if forwarded_scheme(req.headers()).as_deref() != Some("http") { |
|
| 199 | + | return next.run(req).await; |
|
| 200 | + | } |
|
| 201 | + | let to = format!("{base}{}", req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("/")); |
|
| 202 | + | if matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) { |
|
| 203 | + | return axum::response::Redirect::permanent(&to).into_response(); |
|
| 204 | + | } |
|
| 205 | + | (axum::http::StatusCode::BAD_REQUEST, format!("This site takes requests over HTTPS only: send it to {to}\n")).into_response() |
|
| 206 | + | } |
|
| 207 | + | ||
| 167 | 208 | /// A request made with a token its owner marked as an agent's runs with |
|
| 168 | 209 | /// that token's name in [`notify::AGENT`], so what the agent does is told |
|
| 169 | 210 | /// to its owner (oxygenhub#183). |
|
| … | 91 unchanged lines |
modified crates/hub/src/web/session.rs +2 −1
| … | 158 unchanged lines |
||
| 159 | 159 | if let Some(a) = viewer.account() { |
|
| 160 | 160 | accounts::audit(&app.db, Some(a), "logout", &a.username, "").await.ok(); |
|
| 161 | 161 | } |
|
| 162 | - | let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0", auth::SESSION_COOKIE); |
|
| 162 | + | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 163 | + | let cookie = format!("{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}", auth::SESSION_COOKIE); |
|
| 163 | 164 | ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response() |
|
| 164 | 165 | } |
|
| 165 | 166 | ||
| … | 108 unchanged lines |
modified crates/hub/src/web/settings.rs +3 −2
| … | 166 unchanged lines |
||
| 167 | 167 | let Some((theme, _)) = THEMES.iter().find(|(t, _)| *t == f.theme) else { |
|
| 168 | 168 | return Err(Error::bad(shell, "Pick dark, light or match the system.")); |
|
| 169 | 169 | }; |
|
| 170 | + | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 170 | 171 | let backdrop = if f.backdrop.is_empty() { "moving" } else { f.backdrop.as_str() }; |
|
| 171 | 172 | let Some((backdrop, _)) = BACKDROPS.iter().find(|(b, _)| *b == backdrop) else { |
|
| 172 | 173 | return Err(Error::bad(shell, "Pick a moving, still or no background.")); |
|
| … | 1 unchanged lines |
||
| 174 | 175 | Ok(( |
|
| 175 | 176 | StatusCode::SEE_OTHER, |
|
| 176 | 177 | axum::response::AppendHeaders([ |
|
| 177 | - | (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax")), |
|
| 178 | - | (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax")), |
|
| 178 | + | (header::SET_COOKIE, format!("theme={theme}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")), |
|
| 179 | + | (header::SET_COOKIE, format!("backdrop={backdrop}; Path=/; Max-Age=31536000; SameSite=Lax{secure}")), |
|
| 179 | 180 | (header::LOCATION, "/settings".to_string()), |
|
| 180 | 181 | ]), |
|
| 181 | 182 | ) |
|
| … | 520 unchanged lines |
modified crates/hub/tests/accounts.rs +47 −0
| … | 1193 unchanged lines |
||
| 1194 | 1194 | "above the repositories" |
|
| 1195 | 1195 | ); |
|
| 1196 | 1196 | } |
|
| 1197 | + | ||
| 1198 | + | /// #218: on an https site, what a proxy reports as plain http is sent to |
|
| 1199 | + | /// https (pages) or refused (forms), so a password is never taken over |
|
| 1200 | + | /// http; requests made to the hub directly are untouched; cookies are |
|
| 1201 | + | /// Secure. |
|
| 1202 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1203 | + | async fn an_https_site_takes_nothing_over_http() { |
|
| 1204 | + | hermetic(); |
|
| 1205 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 1206 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 1207 | + | config.server.base_url = "https://hub.example.org".into(); |
|
| 1208 | + | config.registration.mode = "open".into(); |
|
| 1209 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 1210 | + | let call = |method: &'static str, path: &'static str, header: Option<(&'static str, &'static str)>, body: Option<&'static str>| { |
|
| 1211 | + | let app = app.clone(); |
|
| 1212 | + | async move { |
|
| 1213 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 1214 | + | if let Some((k, v)) = header { |
|
| 1215 | + | req = req.header(k, v); |
|
| 1216 | + | } |
|
| 1217 | + | if body.is_some() { |
|
| 1218 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1219 | + | } |
|
| 1220 | + | oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap() |
|
| 1221 | + | } |
|
| 1222 | + | }; |
|
| 1223 | + | // A page asked for over http (as the tunnel reports it) goes to https, path and query kept. |
|
| 1224 | + | let r = call("GET", "/login?next=%2Fx", Some(("x-forwarded-proto", "http")), None).await; |
|
| 1225 | + | assert!(r.status().is_redirection(), "{}", r.status()); |
|
| 1226 | + | assert_eq!(r.headers().get(header::LOCATION).unwrap(), "https://hub.example.org/login?next=%2Fx"); |
|
| 1227 | + | // A form over http is refused, whichever header says so. |
|
| 1228 | + | let r = call("POST", "/login", Some(("cf-visitor", r#"{"scheme":"http"}"#)), Some("username=a&password=b")).await; |
|
| 1229 | + | assert_eq!(r.status(), StatusCode::BAD_REQUEST); |
|
| 1230 | + | // Over https, and asked of the hub directly (its runners), it answers. |
|
| 1231 | + | assert_eq!(call("GET", "/login", Some(("x-forwarded-proto", "https")), None).await.status(), StatusCode::OK); |
|
| 1232 | + | assert_eq!(call("GET", "/login", None, None).await.status(), StatusCode::OK); |
|
| 1233 | + | // The session cookie is Secure. |
|
| 1234 | + | let r = call( |
|
| 1235 | + | "POST", |
|
| 1236 | + | "/register", |
|
| 1237 | + | Some(("x-forwarded-proto", "https")), |
|
| 1238 | + | Some("username=andy&email=a%40example.org&password=correct-horse-battery"), |
|
| 1239 | + | ) |
|
| 1240 | + | .await; |
|
| 1241 | + | let cookie = r.headers().get(header::SET_COOKIE).expect("signed in").to_str().unwrap(); |
|
| 1242 | + | assert!(cookie.contains("; Secure") && cookie.contains("HttpOnly"), "{cookie}"); |
|
| 1243 | + | } |
modified docs/deploy.md +13 −0
| … | 35 unchanged lines |
||
| 36 | 36 | | Push token | `/home/oxygenhub/push-token` (also in `hub.toml`); each local checkout has it as `remote.earth.token` | |
|
| 37 | 37 | | Certificate | `/home/oxygenhub/data/keys/tls.crt`, served at `/.well-known/oxyhub-ca.pem`; local copy `~/.config/oxy/earth-ca.pem` set as `remote.earth.ca` | |
|
| 38 | 38 | | Service | `sudo systemctl status oxygenhub`, `sudo journalctl -u oxygenhub` | |
|
| 39 | + | ||
| 40 | + | ## HTTPS only (oxygenhub#218) |
|
| 41 | + | ||
| 42 | + | When `base_url` is `https://…`, the hub takes nothing over plain http |
|
| 43 | + | through a proxy: a request that `X-Forwarded-Proto` (or Cloudflare's |
|
| 44 | + | `CF-Visitor`) reports as http is redirected to the same path on |
|
| 45 | + | `base_url` (GET and HEAD) or refused with 400 (anything else), and every |
|
| 46 | + | cookie it sets is `Secure`. Requests made to the hub directly, such as a |
|
| 47 | + | runner on the same host using `http://127.0.0.1:7300`, carry no such |
|
| 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: it waits |
|
| 50 | + | for the security headers (#222) and the site owner's decision, because |
|
| 51 | + | browsers remember it for months. |