diff --git a/crates/ipx/src/access.rs b/crates/ipx/src/access.rs index 7934af4..0549e52 100644 --- a/crates/ipx/src/access.rs +++ b/crates/ipx/src/access.rs @@ -38,13 +38,14 @@ 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 + let url = certs(team); + self.verify_with(team, aud, token, || fetch(client, &url)).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 { + match fetch(client, &certs(team)).await { Ok(set) => self.store(set), Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"), } @@ -111,10 +112,14 @@ impl Keys { } } -async fn fetch(client: &reqwest::Client, team: &str) -> Result { - let url = format!("https://{team}/cdn-cgi/access/certs"); +/// Where a team publishes its signing keys. +fn certs(team: &str) -> String { + format!("https://{team}/cdn-cgi/access/certs") +} + +async fn fetch(client: &reqwest::Client, url: &str) -> Result { client - .get(&url) + .get(url) .timeout(Duration::from_secs(10)) .send() .await @@ -126,25 +131,25 @@ async fn fetch(client: &reqwest::Client, team: &str) -> Result { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use jsonwebtoken::{EncodingKey, Header}; - const TEAM: &str = "team.cloudflareaccess.com"; - const AUD: &str = "aud-tag"; + pub(crate) const TEAM: &str = "team.cloudflareaccess.com"; + pub(crate) 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 { + pub(crate) 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) }; jsonwebtoken::encode(&h, &claims, &k).unwrap() } - fn claims(aud: &str, exp_in: i64) -> serde_json::Value { + pub(crate) fn claims(aud: &str, exp_in: i64) -> serde_json::Value { let now = jsonwebtoken::get_current_timestamp() as i64; serde_json::json!({ "aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net", @@ -152,13 +157,45 @@ mod tests { }) } - const SIGNER: &[u8] = include_bytes!("../../../tests/data/access-test.der"); + pub(crate) 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 } + /// Keys holding the test signer's, as if fetched from the team. + pub(crate) fn keys() -> Keys { + let keys = Keys::default(); + keys.store(jwks()); + keys + } + + #[tokio::test] + async fn the_keys_are_read_from_the_teams_certs_and_a_failure_is_an_error() { + let app = axum::Router::new() + .route( + "/cdn-cgi/access/certs", + axum::routing::get(|| async { include_str!("../../../tests/data/access-test.jwks.json") }), + ) + .route("/down", axum::routing::get(|| async { axum::http::StatusCode::SERVICE_UNAVAILABLE })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let at = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await }); + let client = reqwest::Client::new(); + + let set = fetch(&client, &format!("http://{at}/cdn-cgi/access/certs")).await.unwrap(); + assert_eq!(set.keys.len(), jwks().keys.len()); + let keys = Keys::default(); + keys.store(set); + let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300)); + assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "the fetched key verifies"); + + assert!(fetch(&client, &format!("http://{at}/down")).await.is_err(), "a 503 is not a key set"); + assert!(fetch(&client, &format!("http://{at}/nothing")).await.is_err(), "nor a 404"); + assert_eq!(certs(TEAM), "https://team.cloudflareaccess.com/cdn-cgi/access/certs"); + } + #[tokio::test] async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() { let keys = Keys::default(); diff --git a/crates/ipx/src/cli.rs b/crates/ipx/src/cli.rs index 4f47cd2..21e8642 100644 --- a/crates/ipx/src/cli.rs +++ b/crates/ipx/src/cli.rs @@ -104,11 +104,10 @@ pub(crate) enum UserCmd { /// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps` /// listing. -pub(crate) async fn user_cmd(ctx: &Arc, cmd: UserCmd) -> Result<()> { - let read_password = || -> Result { - use std::io::Read; +pub(crate) async fn user_cmd(ctx: &Arc, cmd: UserCmd, mut stdin: impl std::io::Read) -> Result<()> { + let mut read_password = || -> Result { let mut buf = String::new(); - std::io::stdin().read_to_string(&mut buf)?; + stdin.read_to_string(&mut buf)?; let pw = buf.trim_end_matches(['\n', '\r']).to_string(); if pw.is_empty() { anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`"); @@ -232,3 +231,50 @@ pub(crate) fn duration(secs: u64) -> String { s => format!("{}d", s / 86_400), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn accounts_are_added_renamed_given_a_password_and_removed() { + let ctx = Arc::new(crate::test_util::test_ctx(crate::config::Config::default()).await); + let user = |cmd, stdin: &'static str| user_cmd(&ctx, cmd, stdin.as_bytes()); + let add = |name: &str, no_password| UserCmd::Add { name: name.into(), admin: false, no_password }; + + user(add(" Ray ", false), "first secret\n").await.unwrap(); + let ray = ctx.db.user_by_name("ray").await.unwrap().expect("lower-cased and trimmed"); + assert!(ray.is_admin, "the first account runs the place"); + assert!( + crate::auth::check_password("first secret".into(), ray.pass_hash.clone()).await, + "the newline is not part of it" + ); + user(add("sam", true), "").await.unwrap(); + let sam = ctx.db.user_by_name("sam").await.unwrap().unwrap(); + assert!(!sam.is_admin && sam.pass_hash.is_none(), "signs in through the proxy"); + + assert!(user(add("RAY", true), "").await.is_err(), "already exists"); + assert!(user(add(" ", true), "").await.is_err(), "a name is required"); + assert!(user(add("dee", false), "").await.is_err(), "no password on stdin"); + user(UserCmd::List, "").await.unwrap(); + + // A new password signs every browser out. + let sid = crate::auth::new_session_token(); + ctx.db.create_session(ray.id, &sid).await.unwrap(); + user(UserCmd::Passwd { name: "ray".into() }, "second secret").await.unwrap(); + let ray = ctx.db.user_by_name("ray").await.unwrap().unwrap(); + assert!(crate::auth::check_password("second secret".into(), ray.pass_hash).await); + assert!(ctx.db.session_user(&sid, 86_400).await.unwrap().is_none(), "signed out"); + assert!(user(UserCmd::Passwd { name: "nobody".into() }, "x").await.is_err()); + + let rename = |name: &str, new_name: &str| UserCmd::Rename { name: name.into(), new_name: new_name.into() }; + assert!(user(rename("sam", "ray"), "").await.is_err(), "taken"); + assert!(user(rename("sam", "a,b"), "").await.is_err(), "the proxy could never send it"); + user(rename("sam", "Sam@X.test"), "").await.unwrap(); + assert_eq!(ctx.db.user_by_name("sam@x.test").await.unwrap().map(|u| u.id), Some(sam.id), "same account"); + + user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.unwrap(); + assert!(ctx.db.user_by_name("sam@x.test").await.unwrap().is_none()); + assert!(user(UserCmd::Rm { name: "sam@x.test".into() }, "").await.is_err(), "already gone"); + } +} diff --git a/crates/ipx/src/main.rs b/crates/ipx/src/main.rs index 0e7e0c4..a66cff2 100644 --- a/crates/ipx/src/main.rs +++ b/crates/ipx/src/main.rs @@ -106,7 +106,7 @@ async fn main() -> Result<()> { catalogue::add(&ctx, &url, folder, keywords, category, list).await } Command::Rm { feed } => catalogue::rm(&ctx, &feed).await, - Command::User { cmd } => cli::user_cmd(&ctx, cmd).await, + Command::User { cmd } => cli::user_cmd(&ctx, cmd, std::io::stdin()).await, Command::Import { file } => catalogue::import(&ctx, &file).await, Command::Export { file } => catalogue::export(&ctx, &file).await, _ => cli::run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index aa84d86..1bb87c3 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1293,6 +1293,157 @@ impl IntoResponse for ApiError { mod tests { use super::*; + use crate::access::tests as access; + use tower::ServiceExt; + + /// The router as the daemon serves it, on a scratch database, with the test signer's keys. + async fn app(cfg: crate::config::Config) -> (Router, Arc) { + let ctx = Arc::new(crate::test_util::test_ctx(cfg).await); + let state = WebState { + ctx: ctx.clone(), + cmds: mpsc::channel(8).0, + events: broadcast::channel(8).0, + access: Arc::new(access::keys()), + playing: Default::default(), + }; + (router(state), ctx) + } + + /// A request from `from`, as the listener would see its peer; the status, Set-Cookie and body. + async fn ask( + app: &Router, + method: &str, + uri: &str, + from: &str, + headers: &[(&str, &str)], + body: Option, + ) -> (u16, String, String) { + let mut req = axum::http::Request::builder().method(method).uri(uri); + for (k, v) in headers { + req = req.header(*k, *v); + } + let req = match body { + Some(b) => req.header("content-type", "application/json").body(axum::body::Body::from(b.to_string())), + None => req.body(axum::body::Body::empty()), + }; + let mut req = req.unwrap(); + req.extensions_mut().insert(ConnectInfo(SocketAddr::new(from.parse().unwrap(), 40000))); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status().as_u16(); + let cookie = resp.headers().get(header::SET_COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned(); + let body = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + (status, cookie, String::from_utf8_lossy(&body).into_owned()) + } + + const CADDY: &str = "192.168.64.10"; + + fn proxied() -> crate::config::Config { + let mut cfg = crate::config::Config::default(); + cfg.web.trusted_header = "Cf-Access-Authenticated-User-Email".into(); + cfg.web.trusted_proxies = vec![CADDY.into()]; + cfg.web.auto_create_users = true; + cfg + } + + #[tokio::test] + async fn a_name_is_taken_from_the_proxys_header_only_from_the_proxy() { + let (app, _) = app(proxied()).await; + let who = [("Cf-Access-Authenticated-User-Email", "Rays@SDF1.net")]; + let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &who, None).await; + assert_eq!(s, 200, "{me}"); + let me: serde_json::Value = serde_json::from_str(&me).unwrap(); + assert_eq!((me["name"].as_str(), me["admin"].as_bool()), (Some("rays@sdf1.net"), Some(true)), "made, first"); + + // Anyone else able to reach the port could send the header and be whoever they liked. + assert_eq!(ask(&app, "GET", "/api/me", "192.168.64.1", &who, None).await.0, 401, "cloudflared itself"); + assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &who, None).await.0, 401); + assert_eq!(ask(&app, "GET", "/api/me", CADDY, &[], None).await.0, 401, "from the proxy, vouching for nobody"); + } + + #[tokio::test] + async fn with_access_set_the_name_is_the_signed_tokens_and_nothing_else() { + let mut cfg = proxied(); + cfg.web.access_team = access::TEAM.into(); + cfg.web.access_aud = access::AUD.into(); + let (app, _) = app(cfg).await; + let rs = jsonwebtoken::Algorithm::RS256; + let good = access::token(access::SIGNER, rs, access::claims(access::AUD, 300)); + fn with(t: &str) -> [(&str, &str); 2] { + [("Cf-Access-Authenticated-User-Email", "someone-else@x.test"), ("Cf-Access-Jwt-Assertion", t)] + } + + let (s, _, me) = ask(&app, "GET", "/api/me", CADDY, &with(&good), None).await; + assert_eq!(s, 200, "{me}"); + assert!(me.contains(r#""name":"rays@sdf1.net""#), "the token's email, not the header's: {me}"); + + let other_app = access::token(access::SIGNER, rs, access::claims("another-app", 300)); + let expired = access::token(access::SIGNER, rs, access::claims(access::AUD, -3600)); + for (t, why) in + [(other_app.as_str(), "another application's"), (expired.as_str(), "expired"), ("x.y.z", "garbage")] + { + assert_eq!(ask(&app, "GET", "/api/me", CADDY, &with(t), None).await.0, 401, "{why}"); + } + let header_only = [("Cf-Access-Authenticated-User-Email", "rays@sdf1.net")]; + assert_eq!(ask(&app, "GET", "/api/me", CADDY, &header_only, None).await.0, 401, "no token"); + let token_only = [("Cf-Access-Jwt-Assertion", good.as_str())]; + assert_eq!(ask(&app, "GET", "/api/me", CADDY, &token_only, None).await.0, 401, "no header to switch it on"); + assert_eq!( + ask(&app, "GET", "/api/me", "10.0.0.5", &with(&good), None).await.0, + 401, + "a good token, not via Caddy" + ); + } + + #[tokio::test] + async fn a_wrong_password_is_refused_and_the_right_one_after_it_signs_in() { + let (app, ctx) = app(crate::config::Config::default()).await; + let hash = crate::auth::hash_password("correct horse").unwrap(); + ctx.db.create_user("dee", Some(&hash), false).await.unwrap(); + let login = |pw: &str| serde_json::json!({ "name": "Dee", "password": pw }); + for _ in 0..3 { + assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("wrong"))).await.0, 401); + } + let (s, _, _) = ask( + &app, + "POST", + "/api/login", + "10.0.0.5", + &[], + Some(serde_json::json!({ "name": "nobody", "password": "x" })), + ) + .await; + assert_eq!(s, 401, "an unknown name has the same answer"); + let (s, cookie, _) = ask(&app, "POST", "/api/login", "10.0.0.5", &[], Some(login("correct horse"))).await; + assert_eq!(s, 200); + let session = cookie.split(';').next().unwrap(); + assert!(session.starts_with("ipx_session=") && cookie.contains("HttpOnly"), "{cookie}"); + let (s, _, me) = ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", session)], None).await; + assert!(s == 200 && me.contains(r#""name":"dee""#), "{s} {me}"); + assert_eq!(ask(&app, "GET", "/api/me", "10.0.0.5", &[("cookie", "ipx_session=forged")], None).await.0, 401); + } + + #[tokio::test] + async fn the_block_list_and_the_typesafe_key_are_checked_before_they_are_saved() { + let (app, ctx) = app(proxied()).await; + let who = [("Cf-Access-Authenticated-User-Email", "dee@x.test")]; + let patch = |b: serde_json::Value| ask(&app, "PATCH", "/api/me", CADDY, &who, Some(b)); + + let many: Vec = (0..501).map(|i| format!("w{i}")).collect(); + assert_eq!(patch(serde_json::json!({ "blocked": many })).await.0, 400, "501 words"); + assert_eq!(patch(serde_json::json!({ "blocked": ["x".repeat(201)] })).await.0, 400, "a 201-character word"); + assert_eq!(patch(serde_json::json!({ "blocked": [" Spoiler ", "spoiler", "", "ads"] })).await.0, 204); + let me = ctx.db.user_by_name("dee@x.test").await.unwrap().unwrap(); + assert_eq!(ctx.db.blocklist(me.id, "").await.unwrap(), ["Spoiler", "ads"], "trimmed, once each"); + + // Refused here, before TypeSafe is asked anything. + for bad in ["has a space", "tab\there", &"k".repeat(501)] { + assert_eq!(patch(serde_json::json!({ "typesafe_key": bad })).await.0, 400, "{bad}"); + } + assert!(!ctx.db.typesafe_key_set(me.id).await.unwrap()); + assert_eq!(patch(serde_json::json!({ "typesafe_key": null })).await.0, 204, "forgetting needs no check"); + assert_eq!(patch(serde_json::json!({ "typesafe_key": " " })).await.0, 204, "nor does an empty one"); + } + #[test] fn icon_picks_are_counted_by_the_hour_and_the_person() { let mut picks = std::collections::HashMap::new();