Tests for proxy sign-in, passwords, account settings and ipx user (#399)

Who gets in had no test above the token check: the router now runs in
tests with a peer address, as the listener gives it. A name in the
proxy's header signs in only from the trusted proxy (not from
cloudflared's gateway or anyone else) and makes the first account an
admin; with Access set, the name is the signed token's, and a token for
another application, an expired one, garbage, a header without a token
or a token without the header are refused, as is a good token not via
Caddy. Wrong passwords and unknown names are 401 and the right one
after them signs in with a session cookie; a forged one is refused.
PATCH /api/me refuses an over-long block list and a malformed TypeSafe
key before asking TypeSafe. The signing keys are read from a local
stand-in for the team's certs, and a 503 or 404 is an error.

ipx user add, list, passwd, rename and rm run against a scratch
database: user_cmd takes its stdin as an argument, so a password can be
given without a terminal, and access's fetch takes the certs address.

There is no 429 for a flood of wrong passwords to test: that is #409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 04:10:29 +00:00
parent b62182e38e
commit 3bb59c215a
4 changed files with 250 additions and 16 deletions

View File

@@ -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<String> {
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<JwkSet> {
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<JwkSet> {
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<JwkSet> {
}
#[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<String> {
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();

View File

@@ -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<Ctx>, cmd: UserCmd) -> Result<()> {
let read_password = || -> Result<String> {
use std::io::Read;
pub(crate) async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd, mut stdin: impl std::io::Read) -> Result<()> {
let mut read_password = || -> Result<String> {
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");
}
}

View File

@@ -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,

View File

@@ -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<Ctx>) {
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<serde_json::Value>,
) -> (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<String> = (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();