OXYGENHUB

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.