diff --git a/CHANGELOG.md b/CHANGELOG.md index b165810..c808705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It says where the token is kept instead: `[web] token` in config.toml. +- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and + `access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as + the email header. Without it, anything on the same Docker host as ipx could send the header. See + docs/sso.md. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 0a20056..9292b43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,8 +183,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the ## Known gaps -* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies` - (documented in [docs/sso.md](docs/sso.md)). +* Nothing at the moment. Add one here when a limitation is known and left in place. # Command output diff --git a/Cargo.lock b/Cargo.lock index afe4a64..59587cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "chrono", "clap", "futures-util", + "jsonwebtoken", "librqbit", "opml", "percent-encoding", @@ -2007,6 +2008,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3786,6 +3803,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5183,6 +5209,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 11a2fc4..b9ac25e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ axum = "0.8.9" chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } clap = { version = "4.6.6", features = ["derive"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] } +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } opml = "1.1.6" percent-encoding = "2.3.2" diff --git a/docs/configuration.md b/docs/configuration.md index 8b5a528..9908cc0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default token = "" # generated and saved on first run trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email" trusted_proxies = ["127.0.0.1", "::1"] +access_team = "" # e.g. ".cloudflareaccess.com" +access_aud = "" # the Access application's AUD tag auto_create_users = true sign_out_url = "" # e.g. "/cdn-cgi/access/logout" session_days = 30 @@ -94,6 +96,9 @@ session_days = 30 disables that path. See [sso.md](sso.md). * **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary for it. Name the proxy, never a subnet. +* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to + carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name + comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token). * **`auto_create_users`** — create an account the first time the proxy vouches for a new name. * **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out, `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where diff --git a/docs/sso.md b/docs/sso.md index 7020c21..35d67f1 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -172,9 +172,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a sides. Never list a LAN address or range: anyone there could then send `Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you. -**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It -trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the -boundary, and is the upgrade if that ever matters. +**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the +header is not enough on its own: the request has to carry the token Cloudflare Access signed, and +a container on Tower cannot make one. + +### Verifying Cloudflare's token + +Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person, +signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied +request, and takes the name from its `email` claim. + +```toml +[web] +access_team = ".cloudflareaccess.com" # Zero Trust → Settings: the team domain +access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag +``` + +ipx fetches the public keys from `https:///cdn-cgi/access/certs` when it starts, and +again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at +most once a minute. It checks the signature (RS256 only), that the audience is this application's +tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while +the keys cannot be fetched; password and token sign-in still work then. + +`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their +place. + +Check it: the busybox request under [Check it](#check-it), which sends the email header without a +token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik. **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign in with them until they are given a password (`ipx user passwd `). diff --git a/src/access.rs b/src/access.rs new file mode 100644 index 0000000..2a4c9a1 --- /dev/null +++ b/src/access.rs @@ -0,0 +1,215 @@ +//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy +//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that +//! address is the Docker gateway: any container there could send the header and be anyone. +//! Access signs the same identity with keys only Cloudflare holds, so checking that signature +//! takes the network out of the question. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; + +/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it. +/// A token naming a key not seen yet refetches, but no more often than this, so a stream of +/// made-up key ids cannot turn every request into a request to Cloudflare. +const REFETCH_EVERY: Duration = Duration::from_secs(60); + +#[derive(Default)] +pub struct Keys { + cache: Mutex, +} + +#[derive(Default)] +struct Cache { + keys: HashMap, + fetched: Option, +} + +#[derive(serde::Deserialize)] +struct Claims { + email: Option, +} + +impl Keys { + /// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an + /// expired token, a key that cannot be had, or no email in it (a service token has none). + pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option { + self.verify_with(team, aud, token, || fetch(client, team)).await + } + + /// Fill the cache before the first request needs it. A failure is only logged: the next + /// request tries again, and until one succeeds the proxy sign-in refuses everyone. + pub async fn prefetch(&self, client: &reqwest::Client, team: &str) { + match fetch(client, team).await { + Ok(set) => self.store(set), + Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"), + } + } + + async fn verify_with(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let kid = decode_header(token).ok()?.kid?; + let key = match self.key(&kid) { + Some(k) => k, + None => { + if !self.may_refetch() { + return None; + } + match fetch().await { + Ok(set) => self.store(set), + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"); + return None; + } + } + self.key(&kid)? + } + }; + // RS256 only: a token that names HS256 or none is refused here, before its signature + // is looked at, rather than checked with the public key as if it were a secret. + let mut v = Validation::new(Algorithm::RS256); + v.set_audience(&[aud]); + v.set_issuer(&[format!("https://{team}")]); + v.validate_nbf = true; + match decode::(token, &key, &v) { + Ok(data) => crate::auth::name_from_header(&data.claims.email?), + Err(e) => { + tracing::warn!(error = %e, "refused a Cloudflare Access token"); + None + } + } + } + + fn key(&self, kid: &str) -> Option { + self.cache.lock().unwrap().keys.get(kid).cloned() + } + + /// Takes the slot as it answers, so two requests at once do not both fetch. + fn may_refetch(&self) -> bool { + let mut c = self.cache.lock().unwrap(); + if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) { + return false; + } + c.fetched = Some(Instant::now()); + true + } + + /// Replaces the whole set, so a key Cloudflare has retired stops being accepted. + fn store(&self, set: JwkSet) { + let keys = set + .keys + .iter() + .filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?))) + .collect(); + let mut c = self.cache.lock().unwrap(); + c.keys = keys; + c.fetched = Some(Instant::now()); + } +} + +async fn fetch(client: &reqwest::Client, team: &str) -> Result { + let url = format!("https://{team}/cdn-cgi/access/certs"); + client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + .with_context(|| format!("fetching {url}"))? + .error_for_status()? + .json() + .await + .context("reading the signing keys") +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp}; + + const TEAM: &str = "team.cloudflareaccess.com"; + const AUD: &str = "aud-tag"; + + fn jwks() -> JwkSet { + serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap() + } + + fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String { + let mut h = Header::new(alg); + h.kid = Some("k1".into()); + let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) }; + encode(&h, &claims, &k).unwrap() + } + + fn claims(aud: &str, exp_in: i64) -> serde_json::Value { + let now = get_current_timestamp() as i64; + serde_json::json!({ + "aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net", + "iat": now, "nbf": now, "exp": now + exp_in, "type": "app", + }) + } + + const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der"); + const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der"); + + async fn check(keys: &Keys, t: &str) -> Option { + keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await + } + + #[tokio::test] + async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() { + let keys = Keys::default(); + keys.store(jwks()); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header"); + + let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300)); + assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application"); + + let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600)); + assert_eq!(check(&keys, &expired).await, None, "expired"); + + let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300)); + assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's"); + + // HMAC and none, the classic ways to get a token past a verifier that trusts its header. + let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300)); + assert_eq!(check(&keys, &hs).await, None, "HS256"); + let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ", + ok.split('.').nth(1).unwrap()); + assert_eq!(check(&keys, &none).await, None, "alg none"); + } + + #[tokio::test] + async fn an_unknown_key_refetches_once_a_minute_at_most() { + let keys = Keys::default(); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + let fetches = std::sync::atomic::AtomicUsize::new(0); + let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } }; + assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"), + "a key not cached yet is fetched"); + assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1); + + // A made-up key id straight after: not fetched again. + let mut h = Header::new(Algorithm::RS256); + h.kid = Some("nobody".into()); + let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap(); + let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } }; + assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None); + assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited"); + } + + #[tokio::test] + async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() { + let keys = Keys::default(); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await; + assert_eq!(got, None); + } +} diff --git a/src/config.rs b/src/config.rs index b63967f..2e05a20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,14 @@ pub struct Web { /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. pub trusted_proxies: Vec, + /// Cloudflare Access's team domain, `.cloudflareaccess.com`. With `access_aud`, the + /// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name + /// from it: a header from a trusted address is otherwise all it asks for, and on a Docker + /// host any container can send one from the gateway's address. + pub access_team: String, + /// The Access application's Application Audience (AUD) tag. Empty, with `access_team`, + /// leaves the signature unchecked. + pub access_aud: String, /// Create an account the first time the proxy vouches for a name it has not seen. pub auto_create_users: bool, /// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick @@ -96,6 +104,8 @@ impl Default for Web { token: String::new(), trusted_header: String::new(), trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], + access_team: String::new(), + access_aud: String::new(), auto_create_users: true, sign_out_url: String::new(), session_days: 30, @@ -107,6 +117,12 @@ impl Web { pub fn binds_publicly(&self) -> bool { !self.bind.starts_with("127.") && !self.bind.starts_with("localhost") } + + /// Both halves of the Access check, or None while either is unset. + pub fn access(&self) -> Option<(&str, &str)> { + (!self.access_team.is_empty() && !self.access_aud.is_empty()) + .then_some((self.access_team.as_str(), self.access_aud.as_str())) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/src/main.rs b/src/main.rs index c5e29eb..073cf38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod access; mod auth; mod config; mod db; @@ -567,10 +568,16 @@ async fn start_web( tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); } + let access = Arc::new(access::Keys::default()); + if let Some((team, _)) = ctx.cfg().web.access() { + let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned()); + tokio::spawn(async move { access.prefetch(&ctx.client, &team).await }); + } let state = web::WebState { ctx: ctx.clone(), cmds: cmds.clone(), events: events.clone(), + access, }; Ok(Some(tokio::spawn(async move { if let Err(e) = web::serve(state, &bind).await { diff --git a/src/web.rs b/src/web.rs index 16da0b0..407b767 100644 --- a/src/web.rs +++ b/src/web.rs @@ -29,6 +29,8 @@ pub struct WebState { pub ctx: Arc, pub cmds: mpsc::Sender, pub events: broadcast::Sender, + /// Cloudflare Access's signing keys, fetched once and kept. + pub access: Arc, } pub fn router(state: WebState) -> Router { @@ -99,7 +101,7 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re let token = cfg.web.token.clone(); // 1. A header, but only from a hop we were told to believe. - let vouched = vouched_name(&cfg, &req); + let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await; let mut set_cookie: Option = None; let mut user = None; @@ -201,20 +203,31 @@ struct Proxied(bool); /// The name the proxy vouches for, when this request came from one of `trusted_proxies` and /// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and -/// be whoever they liked. -fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option { - let peer = req - .extensions() - .get::>() - .map(|c| c.0.ip().to_string()) - .unwrap_or_default(); +/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a +/// token Cloudflare Access signed, and the name is the one in the token. +/// +/// Takes the request's parts rather than the request: a `&Request` held across the await makes +/// the future unsendable, as a body is not `Sync`. +async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option { if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { return None; } - req.headers() - .get(&cfg.web.trusted_header) - .and_then(|v| v.to_str().ok()) - .and_then(crate::auth::name_from_header) + let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()); + let Some((team, aud)) = cfg.web.access() else { + return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header); + }; + // The plain header still has to be there, as it is what switches this path on for a + // request; who it names is the token's to say. + header(&cfg.web.trusted_header)?; + let token = header("Cf-Access-Jwt-Assertion")?; + state.access.verify(&state.ctx.client, team, aud, token).await +} + +fn peer(req: &Request) -> String { + req.extensions() + .get::>() + .map(|c| c.0.ip().to_string()) + .unwrap_or_default() } /// Handlers take `User` to say they need one; the auth layer put it there, and nothing @@ -474,7 +487,7 @@ async fn remove_user( /// The password form, except for someone the proxy vouches for: they are signed in already, and /// the form only made it look as if they were not. async fn login_page(State(state): State, req: Request) -> Response { - if vouched_name(&state.ctx.cfg(), &req).is_some() { + if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() { return Redirect::to("/").into_response(); } ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html")))) diff --git a/tests/data/access-forger.der b/tests/data/access-forger.der new file mode 100644 index 0000000..bb13737 Binary files /dev/null and b/tests/data/access-forger.der differ diff --git a/tests/data/access-test.der b/tests/data/access-test.der new file mode 100644 index 0000000..6c33ae5 Binary files /dev/null and b/tests/data/access-test.der differ diff --git a/tests/data/access-test.jwks.json b/tests/data/access-test.jwks.json new file mode 100644 index 0000000..f2d2db9 --- /dev/null +++ b/tests/data/access-test.jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kid": "k1", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "e": "AQAB", + "n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw" + } + ] +}